1. Introducción
Cloud Spanner es un servicio de bases de datos relacionales completamente administrado, distribuido a nivel global y escalable horizontalmente que proporciona transacciones ACID y semántica de SQL sin renunciar al rendimiento y la alta disponibilidad.
Estas funciones hacen que Spanner sea una excelente opción para la arquitectura de los juegos que desean habilitar una base de jugadores global o que se preocupan por la coherencia de los datos.
En este lab, crearás dos servicios de Go que interactúan con una base de datos regional de Spanner para permitir que los jugadores se registren y comiencen a jugar.

A continuación, generarás datos con el framework de carga de Python Locust.io para simular que los jugadores se registran y juegan. Luego, consultarás Spanner para determinar cuántos jugadores están jugando y algunas estadísticas sobre los juegos ganados en comparación con los juegos jugados.
Por último, limpiarás los recursos que se crearon en este lab.
Qué compilarás
Como parte de este lab, harás lo siguiente:
- Crear una instancia de Spanner
- Implementa un servicio de perfil escrito en Go para controlar el registro de jugadores
- Implementa un servicio de creación de partidas escrito en Go para asignar jugadores a los juegos, determinar ganadores y actualizar las estadísticas de los jugadores.
Qué aprenderás
- Cómo configurar una instancia de Cloud Spanner
- Cómo crear una base de datos y un esquema de juegos
- Cómo implementar apps en Go para que funcionen con Cloud Spanner
- Cómo generar datos con Locust
- Cómo consultar datos en Cloud Spanner para responder preguntas sobre juegos y jugadores
Requisitos
2. Configuración y requisitos
Crea un proyecto
Si aún no tienes una Cuenta de Google (Gmail o Google Apps), debes crear una. Accede a Google Cloud Platform Console ( console.cloud.google.com) y crea un proyecto nuevo.
Si ya tienes un proyecto, haz clic en el menú desplegable de selección de proyectos en la parte superior izquierda de la Console:

y haz clic en el botón “PROYECTO NUEVO” en el diálogo resultante para crear un proyecto nuevo:

Si aún no tienes un proyecto, deberías ver un cuadro de diálogo como este para crear el primero:

El cuadro de diálogo de creación posterior del proyecto te permite ingresar los detalles de tu proyecto nuevo:

Recuerda el ID del proyecto, que es un nombre único en todos los proyectos de Google Cloud (el nombre anterior ya se encuentra en uso y no lo podrá usar). Se hará referencia a él más adelante en este codelab como PROJECT_ID.
A continuación, si aún no lo has hecho, deberás habilitar la facturación en Developers Console para usar los recursos de Google Cloud y habilitar la API de Cloud Spanner.

Ejecutar este codelab debería costar solo unos pocos dólares, pero su costo podría aumentar si decides usar más recursos o si los dejas en ejecución (consulta la sección “Limpiar” al final de este documento). Los precios de Google Cloud Spanner se documentan aquí.
Los usuarios nuevos de Google Cloud Platform están aptas para obtener una prueba gratuita de $300, por lo que este codelab es completamente gratuito.
Configuración de Google Cloud Shell
Si bien Google Cloud y Spanner se pueden operar de manera remota desde tu laptop, en este codelab usaremos Google Cloud Shell, un entorno de línea de comandos que se ejecuta en la nube.
Esta máquina virtual basada en Debian está cargada con todas las herramientas de desarrollo que necesitarás. Ofrece un directorio principal persistente de 5 GB y se ejecuta en Google Cloud, lo que permite mejorar considerablemente el rendimiento de la red y la autenticación. Esto significa que todo lo que necesitarás para este Codelab es un navegador (sí, funciona en una Chromebook).
- Para activar Cloud Shell desde la consola de Cloud, solo haz clic en Activar Cloud Shell
(el aprovisionamiento y la conexión al entorno debería llevar solo unos minutos).


Una vez que te conectes a Cloud Shell, deberías ver que ya te autenticaste y que el proyecto ya se configuró con tu PROJECT_ID.
gcloud auth list
Resultado del comando
Credentialed accounts:
- <myaccount>@<mydomain>.com (active)
gcloud config list project
Resultado del comando
[core]
project = <PROJECT_ID>
Si, por algún motivo, el proyecto no está configurado, solo emite el siguiente comando:
gcloud config set project <PROJECT_ID>
¿No encuentras tu PROJECT_ID? Observa el ID que usaste en los pasos de configuración o búscalo en el panel de la consola de Cloud:

Cloud Shell también configura algunas variables de entorno de forma predeterminada, lo que puede resultar útil cuando ejecutas comandos futuros.
echo $GOOGLE_CLOUD_PROJECT
Resultado del comando
<PROJECT_ID>
Descarga el código
En Cloud Shell, puedes descargar el código de este lab. Esta versión se basa en la versión v0.1.0, así que consulta esa etiqueta:
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
Resultado del comando
Cloning into 'spanner-gaming-sample'...
*snip*
Switched to a new branch 'v0.1.0-branch'
Configura el generador de cargas de Locust
Locust es un framework de prueba de carga de Python que resulta útil para probar los extremos de la API de REST. En este codelab, destacaremos 2 pruebas de carga diferentes en el directorio "generators":
- authentication_server.py: Contiene tareas para crear jugadores y obtener un jugador aleatorio para imitar búsquedas de un solo punto.
- match_server.py: Contiene tareas para crear y cerrar juegos. Cuando crees juegos, se asignarán 100 jugadores aleatorios que no estén jugando en ese momento. Cuando se cierran los juegos, se actualizan las estadísticas de games_played y games_won, y se permite que se asigne a esos jugadores a un juego futuro.
Para ejecutar Locust en Cloud Shell, necesitarás Python 3.7 o una versión posterior. Cloud Shell incluye Python 3.9, por lo que solo debes validar la versión:
python -V
Resultado del comando
Python 3.9.12
Ahora puedes instalar los requisitos de Locust.
pip3 install -r requirements.txt
Resultado del 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
Ahora, actualiza la variable PATH para que se pueda encontrar el objeto binario locust recién instalado:
PATH=~/.local/bin":$PATH"
which locust
Resultado del comando
/home/<user>/.local/bin/locust
Resumen
En este paso, configuraste tu proyecto si aún no tenías uno, activaste Cloud Shell y descargaste el código para este lab.
Por último, configurarás Locust para la generación de carga más adelante en el lab.
Cuál es el próximo paso
A continuación, configurarás la instancia y la base de datos de Cloud Spanner.
3. Crea una instancia y una base de datos de Spanner
Crea la instancia de Spanner
En este paso, configuraremos nuestra instancia de Spanner para el codelab. Busca la entrada de Spanner
en el menú de opciones superior izquierdo
o ingresa “/” y escribe “Spanner”

A continuación, haz clic en
y completa el formulario. Para ello, ingresa el nombre de la instancia cloudspanner-gaming, elige una configuración (selecciona una instancia regional, como us-central1) y establece la cantidad de nodos. Para este codelab, solo necesitaremos 500 processing units.
Por último, pero no menos importante, haz clic en “Crear” y, en segundos, selecciona una instancia de Cloud Spanner a su disposición.

Crea la base de datos y el esquema
Una vez que se ejecute la instancia, podrás crear la base de datos. Spanner permite tener varias bases de datos en una sola instancia.
La base de datos es donde defines tu esquema. También puedes controlar quién tiene acceso a la base de datos, configurar la encriptación personalizada, configurar el optimizador y establecer el período de retención.
En las instancias multirregionales, también puedes configurar el líder predeterminado. Obtén más información sobre las bases de datos en Spanner.
Para este codelab, crearás la base de datos con las opciones predeterminadas y proporcionarás el esquema en el momento de la creación.
En este lab, se crearán dos tablas: players y games.

Los jugadores pueden participar en muchos juegos a lo largo del tiempo, pero solo en uno a la vez. Los jugadores también tienen estadísticas como un tipo de datos JSON para hacer un seguimiento de estadísticas interesantes, como games_played y games_won. Dado que es posible que se agreguen otras estadísticas más adelante, esta es, en efecto, una columna sin esquema para los jugadores.
Games hace un seguimiento de los jugadores que participaron con el tipo de datos ARRAY de Spanner. Los atributos de ganador y finalizado de un juego no se completan hasta que se cierra el juego.
Hay una clave externa para garantizar que el current_game del jugador sea un juego válido.
Ahora, haz clic en "Crear base de datos" en la descripción general de la instancia para crear la base de datos:

Luego, completa los detalles. Las opciones importantes son el nombre de la base de datos y el dialecto. En este ejemplo, llamamos a la base de datos sample-game y elegimos el dialecto de SQL estándar de Google.
En el esquema, copia y pega este DDL en el cuadro:
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);
Luego, haz clic en el botón de crear y espera unos segundos a que se cree tu base de datos.
La página de creación de la base de datos debería verse así:

Ahora, debes configurar algunas variables de entorno en Cloud Shell para usarlas más adelante en el lab. Por lo tanto, toma nota del ID de la instancia y establece INSTANCE_ID y DATABASE_ID en Cloud Shell.

export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
Resumen
En este paso, creaste una instancia de Spanner y la base de datos sample-game. También definiste el esquema que usa este juego de ejemplo.
Cuál es el próximo paso
A continuación, implementarás el servicio de perfil para permitir que los jugadores se registren y jueguen.
4. Implementa el servicio de perfil
Descripción general del servicio
El servicio de perfil es una API de REST escrita en Go que aprovecha el framework de Gin.

En esta API, los jugadores pueden registrarse para jugar. Se crea con un simple comando POST que acepta el nombre, el correo electrónico y la contraseña del jugador. La contraseña se encripta con bcrypt y el hash se almacena en la base de datos.
El correo electrónico se trata como un identificador único, mientras que el nombre del jugador se usa con fines de visualización en el juego.
Actualmente, esta API no controla el acceso, pero puedes implementarlo como un ejercicio adicional.
El archivo ./src/golang/profile-service/main.go para el servicio de perfil expone dos extremos principales de la siguiente manera:
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())
}
El código de esos extremos se enrutará al modelo 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 de las primeras acciones que realiza el servicio es establecer la conexión de Spanner. Se implementa a nivel del servicio para crear el grupo de sesiones del servicio.
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 y PlayerStats son structs definidos de la siguiente manera:
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 función para agregar al jugador aprovecha una inserción de DML dentro de una transacción de lectura y escritura, ya que agregar jugadores es una sola instrucción en lugar de inserciones por lotes. La función se ve de la siguiente manera:
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
}
Para recuperar un jugador según su UUID, se emite una lectura simple. Esto recupera el playerUUID, player_name, email y las estadísticas del jugador.
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
}
De forma predeterminada, el servicio se configura con variables de entorno. Consulta la sección pertinente del archivo ./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
}
Puedes ver que el comportamiento predeterminado es ejecutar el servicio en localhost:8080.
Con esta información, es hora de ejecutar el servicio.
Ejecuta el servicio de perfil
Ejecuta el servicio con el comando go. Esto descargará las dependencias y establecerá el servicio que se ejecuta en el puerto 8080:
cd ~/spanner-gaming-sample/src/golang/profile-service
go run . &
Resultado del 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
Para probar el servicio, ejecuta 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"}'
Resultado del 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"
Resumen
En este paso, implementaste el servicio de perfil que permite a los jugadores registrarse para jugar tu juego y probaste el servicio emitiendo una llamada a la API de POST para crear un jugador nuevo.
Próximos pasos
En el siguiente paso, implementarás el servicio de matchmaking.
5. Implementa el servicio de búsqueda de parejas
Descripción general del servicio
El servicio de emparejamiento es una API de REST escrita en Go que aprovecha el framework de Gin.

En esta API, los juegos se crean y cierran. Cuando se crea un juego, se asignan 10 jugadores que no están jugando en ese momento.
Cuando se cierra un juego, se selecciona un ganador de forma aleatoria y se ajustan las estadísticas de cada jugador para games_played y games_won. Además, se actualiza cada jugador para indicar que ya no está jugando y, por lo tanto, está disponible para jugar en el futuro.
El archivo ./src/golang/matchmaking-service/main.go para el servicio de matchmaking sigue una configuración y un código similares a los del servicio profile, por lo que no se repiten aquí. Este servicio expone dos extremos principales de la siguiente manera:
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())
}
Este servicio proporciona un struct Game, así como structs Player y PlayerStats reducidos:
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"`
}
Para crear un juego, el servicio de creación de partidas selecciona al azar a 100 jugadores que no estén jugando en ese momento.
Las mutaciones de Spanner se eligen para crear el juego y asignar a los jugadores, ya que las mutaciones tienen un mejor rendimiento que el DML para los cambios grandes.
// 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 selección aleatoria de jugadores se realiza con SQL a través de la capacidad TABLESPACE RESERVOIR de GoogleSQL.
Cerrar un juego es un poco más complicado. Implica elegir un ganador aleatorio entre los jugadores, marcar la hora en que finaliza el juego y actualizar las estadísticas de cada jugador para games_played y games_won.
Debido a esta complejidad y a la cantidad de cambios, se vuelven a elegir mutaciones para cerrar el juego.
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 configuración se vuelve a controlar a través de variables de entorno, como se describe en ./src/golang/matchmaking-service/config/config.go para el servicio.
// 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")
Para evitar conflictos con el servicio de perfil, este servicio se ejecuta en localhost:8081 de forma predeterminada.
Con esta información, es hora de ejecutar el servicio de búsqueda de partidas.
Ejecuta el servicio de búsqueda de parejas
Ejecuta el servicio con el comando go. Esto establecerá el servicio que se ejecuta en el puerto 8082. Este servicio tiene muchas de las mismas dependencias que el servicio de perfil, por lo que no se descargarán dependencias nuevas.
cd ~/spanner-gaming-sample/src/golang/matchmaking-service
go run . &
Resultado del 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 juego
Prueba el servicio para crear un juego. Primero, abre una terminal nueva en Cloud Shell:

Luego, ejecuta el siguiente comando curl:
curl http://localhost:8081/games/create \
--include \
--header "Content-Type: application/json" \
--request "POST"
Resultado del 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"
Cerrar el juego
curl http://localhost:8081/games/close \
--include \
--header "Content-Type: application/json" \
--data '{"gameUUID": "f45b0f7f-405b-4e67-a3b8-a624e990285d"}' \
--request "PUT"
Resultado del 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"
Resumen
En este paso, implementaste el servicio de matchmaking para controlar la creación de juegos y la asignación de jugadores a ese juego. Este servicio también se encarga de cerrar un juego, lo que elige un ganador aleatorio y actualiza las estadísticas de todos los jugadores del juego para games_played y games_won.
Próximos pasos
Ahora que tus servicios están en funcionamiento, es momento de que los jugadores se registren y jueguen.
6. Comienza a jugar
Ahora que los servicios de perfil y de matchmaking están en funcionamiento, puedes generar carga con los generadores de Locust proporcionados.
Locust ofrece una interfaz web para ejecutar los generadores, pero en este lab usarás la línea de comandos (opción –headless).
Registra a los jugadores
Primero, deberás generar jugadores.
El código de Python para crear jugadores en el archivo ./generators/authentication_server.py se ve de la siguiente manera:
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'")
Los nombres, correos electrónicos y contraseñas de los jugadores se generan de forma aleatoria.
Una segunda tarea recuperará a los jugadores que se registraron correctamente para generar carga de lectura.
@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]")
El siguiente comando llama al archivo ./generators/authentication_server.py que generará nuevos jugadores durante 30 s (t=30s) con una simultaneidad de dos subprocesos a la vez (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
Los jugadores se unen a los juegos
Ahora que tienes jugadores registrados, querrán comenzar a jugar.
El código de Python para crear y cerrar juegos en el archivo ./generators/match_server.py se ve de la siguiente manera:
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)
Cuando se ejecute este generador, se abrirán y cerrarán juegos en una proporción de 2:1 (abrir:cerrar). Este comando ejecutará el generador durante 10 segundos (-t=10s):
locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s
Resumen
En este paso, simulaste que los jugadores se registraban para jugar y, luego, ejecutaste simulaciones para que los jugadores jugaran con el servicio de matchmaking. En estas simulaciones, se aprovechó el framework de Locust Python para emitir solicitudes a la API de REST de nuestros servicios.
Puedes modificar el tiempo dedicado a crear jugadores y jugar, así como la cantidad de usuarios simultáneos (-u).
Próximos pasos
Después de la simulación, querrás consultar varias estadísticas con Spanner.
7. Recupera estadísticas del juego
Ahora que simulamos que los jugadores pueden registrarse y jugar, debes revisar tus estadísticas.
Para ello, usa Cloud Console para enviar solicitudes de consulta a Spanner.

Cómo verificar si los juegos están abiertos o cerrados
Un juego cerrado es aquel que tiene la marca de tiempo finalizado completada, mientras que un juego abierto tendrá finalizado como NULL. Este valor se establece cuando se cierra el juego.
Por lo tanto, esta consulta te permitirá verificar cuántos juegos están abiertos y cuántos están cerrados:
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
)
Resultado:
|
|
|
|
|
|
Verificación de la cantidad de jugadores que juegan y los que no
Un jugador está jugando si se establece su columna current_game. De lo contrario, no está jugando.
Por lo tanto, para comparar cuántos jugadores están jugando y cuántos no, usa esta consulta:
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
)
Resultado:
|
|
|
|
|
|
Determina los ganadores principales
Cuando se cierra un juego, se selecciona al azar a uno de los jugadores como ganador. La estadística games_won de ese jugador se incrementa cuando se cierra el juego.
SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;
Resultado:
playerUUID | estadísticas |
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} |
Resumen
En este paso, revisaste varias estadísticas de jugadores y juegos usando Cloud Console para consultar Spanner.
Próximos pasos
A continuación, es hora de limpiar.
8. Limpieza (opcional)
Para limpiar, ve a la sección de Cloud Spanner de la consola de Cloud y borra la instancia “cloudspanner-gaming” que creamos en el paso del codelab denominado “Configurar una instancia de Cloud Spanner”.
9. ¡Felicitaciones!
Felicitaciones. Implementaste correctamente un juego de ejemplo en Spanner.
Próximos pasos
En este lab, se te presentaron varios temas relacionados con el trabajo con Spanner a través del controlador de Go. Debería proporcionarte una mejor base para comprender conceptos críticos, como los siguientes:
- Diseño de esquemas
- DML vs. Mutaciones
- Trabaja con Golang
Asegúrate de consultar el codelab de Publicación comercial de juegos de Cloud Spanner para ver otro ejemplo de cómo trabajar con Spanner como backend de tu juego.