Cloud Spanner: Introducción al desarrollo de videojuegos

1. Introducción

Cloud Spanner es un servicio de base de datos relacional completamente administrado, escalable horizontalmente y distribuido a nivel global que proporciona transacciones ACID y semántica de SQL sin renunciar al rendimiento ni a la alta disponibilidad.

Estas funciones hacen que Spanner sea ideal para la arquitectura de videojuegos que desean habilitar una base de jugadores global o les preocupa 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.

413fdd57bb0b68bc.png

A continuación, generarás datos aprovechando 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 partidos ganados contra partidos jugados.

Por último, limpiarás los recursos que se crearon en este lab.

Qué compilarás

Como parte de este lab, aprenderás a hacer lo siguiente:

  • Crear una instancia de Spanner
  • Implementa un servicio de perfil escrito en Go para controlar el registro del jugador
  • Implementa un servicio de creación de partidas escrito en Go para asignar jugadores a los juegos, determinar ganadores y actualizar las preferencias de los jugadores. estadísticas de los partidos.

Qué aprenderás

  • Cómo configurar una instancia de Cloud Spanner
  • Cómo crear un esquema y una base de datos de juegos
  • Cómo implementar apps de Go para que funcionen con Cloud Spanner
  • Cómo generar datos con Locust
  • Cómo consultar datos en Cloud Spanner para responder preguntas sobre videojuegos y jugadores.

Requisitos

  • Un proyecto de Google Cloud que esté conectado a una cuenta de facturación
  • Un navegador, como Chrome o Firefox.

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 la consola de Google Cloud Platform ( 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:

6c9406d9b014760.png

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

949d83c8a4ee17d9.png

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

870a3cbd6541ee86.png

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

6a92c57d3250a4b3.png

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 mencionará 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.

15d0ef27a8fbab27.png

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

  1. Para activar Cloud Shell desde la consola de Cloud, simplemente haz clic en Activar Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (el aprovisionamiento y la conexión al entorno debería tardar solo unos momentos).

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

Captura de pantalla del 14 de junio de 2017 a las 10.13.43 p.m. .png

Una vez conectado a Cloud Shell, deberías ver que ya estás autenticado y que el proyecto ya está configurado 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>

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

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

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. Se basa en la versión v0.1.0, así que verifica 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 carga Locust

Locust es un framework de prueba de carga de Python que es útil para probar los extremos de la API de REST. En este codelab, tenemos 2 pruebas de carga diferentes en los generadores que destacaremos:

  • authentication_server.py: Contiene tareas para crear jugadores y hacer que un jugador aleatorio imite búsquedas de un solo punto.
  • match_server.py: Contiene tareas para crear juegos y cerrar juegos. Si se crean juegos, se asignarán 100 jugadores aleatorios que no están jugando. Los juegos cerrados actualizarán las estadísticas de games_played y games_won, y permitirá que esos jugadores se asignen a un juego futuro.

Para ejecutar Locust en Cloud Shell, necesitarás Python 3.7 o superior. 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 para 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 ruta de acceso 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 lo tenías), activaste Cloud Shell y descargaste el código para este lab.

Por último, configurarás Locust para generar cargas 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, configuramos nuestra instancia de Spanner para el codelab. Busca la entrada de Spanner 1a6580bd3d3e6783.png en el menú de opciones superior izquierdo 3129589f7bc9e5ce.png o ingresa “/” y escribe “Spanner”

36e52f8df8e13b99.png

A continuación, haz clic en 95269e75bc8c3e4d.png y completa el formulario ingresando el nombre de instancia cloudspanner-gaming para tu instancia, 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.

4457c324c94f93e6.png

Crea la base de datos y el esquema

Una vez que la instancia esté en ejecución, podrás crear la base de datos. Spanner permite múltiples 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, establecer 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 opciones predeterminadas y proporcionarás el esquema en el momento de su creación.

En este lab, se crearán dos tablas: players y games.

77651ac12e47fe2a.png

Los jugadores pueden participar en muchos juegos a lo largo del tiempo, pero solo en un juego 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. Debido a que se pueden agregar otras estadísticas más adelante, esta es, en efecto, una columna sin esquemas para los jugadores.

Los juegos realizan 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 propagan hasta que este se cierra.

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” para crear la base de datos. en la descripción general de la instancia:

a820db6c4a4d6f2d.png

Y, luego, completen los detalles. Las opciones importantes son el nombre de la base de datos y el dialecto. En este ejemplo, le asignamos el nombre sample-game a la base de datos y elegimos el dialecto SQL estándar de Google.

Para 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 creación y espera unos segundos hasta que se cree la base de datos.

La página para crear una base de datos debería verse de la siguiente manera:

d39d358dc7d32939.png

Ahora, debes configurar algunas variables de entorno en Cloud Shell para usarlas más adelante en el codelab. Toma nota del ID de la instancia y configura INSTANCE_ID y DATABASE_ID en Cloud Shell

f6f98848d3aea9c.png

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

Cuál es el próximo paso

A continuación, implementarás el servicio de perfiles para permitir que los jugadores se registren para jugar.

4. Implementa el servicio de perfiles

Descripción general del servicio

El servicio de perfiles es una API de REST escrita en Go que aprovecha el framework de gin.

4fce45ee6c858b3e.png

En esta API, los jugadores pueden registrarse para jugar. Esto se crea mediante un comando POST simple que acepta el nombre de jugador, el correo electrónico y la contraseña. La contraseña está encriptada con bcrypt y el hash se almacena en la base de datos.

Email se considera un identificador único, mientras que player_name se usa con fines de visualización en el juego.

Actualmente, esta API no controla el acceso, pero puedes implementarlas como un ejercicio adicional.

El archivo ./src/golang/profile-service/main.go para el servicio de perfiles 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())
}

Y 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 cosas que hace el servicio es establecer la conexión de Spanner. Esto 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 estructuras definidas 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 el reproductor aprovecha una inserción de DML dentro de una transacción de ReadWrite, porque agregar jugadores es una sola declaració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 reproductor según su UUID, se emite una operación de lectura simple. Esta acción recupera los playerUUID, player_name, el correo electrónico y stats 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 correspondiente 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 perfiles

Ejecuta el servicio con el comando go. Con esta acción, se descargarán las dependencias y se 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

Prueba el servicio mediante la ejecución del 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 perfiles que permite a los jugadores registrarse para jugar tu juego, y probaste el servicio emitiendo una llamada a la API POST para crear un jugador nuevo.

Próximos pasos

En el siguiente paso, implementarás el servicio de creación de partidas.

5. Implementa el servicio de creación de partidas

Descripción general del servicio

El servicio de creación de partidas es una API de REST escrita en Go que aprovecha el framework de gin.

9aecd571df0dcd7c.png

En esta API, los juegos se crean y cerran. Cuando se crea un juego, se asignan a este 10 jugadores que no están jugando en ese momento.

Cuando un partido se cierra, se selecciona un ganador de forma aleatoria, y cada jugador las estadísticas de games_played y games_won se ajustan. Además, cada jugador se actualiza para indicar que ya no está jugando y, por lo tanto, están disponibles para jugar en el futuro.

El archivo ./src/golang/matchmaking-service/main.go para el servicio de creación de partidas sigue una configuración y un código similares a los del servicio de perfil, por lo que no se repite 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 una estructura Game, además de estructuras Player y PlayerStats reducidas:

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 toma una selección aleatoria de 100 jugadores que no están jugando ningún juego.

Se eligen mutaciones de Spanner para crear el juego y asignar a los jugadores, ya que estas tienen un mejor rendimiento que las de DML en el caso de 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 reproductores se realiza con SQL mediante la capacidad TABLESPACE RESERVOIR de GoogleSQL.

Cerrar un juego es un poco más complicado. Implica elegir un ganador al azar entre los jugadores, marcar el momento en el que termina el juego y actualizar el historial de cada jugador estadísticas de games_played y games_won.

Debido a esta complejidad y 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 mediante 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 el momento de ejecutar el servicio de creación de partidas.

Cómo ejecutar el servicio de creación de partidas

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 perfiles, por lo que no se descargarán las 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:

90eceac76a6bb90b.png

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 creación de partidas para controlar la creación de juegos y la asignación de jugadores a ese juego. Este servicio también controla el cierre de un juego, el cual elige un ganador al azar y actualiza la información de todos los jugadores estadísticas de games_played y games_won.

Próximos pasos

Ahora que los servicios están en funcionamiento, es momento de que los jugadores se registren y jueguen.

6. Comenzar a jugar

Ahora que el perfil y los servicios de creación de partidas están en ejecución, 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 jugadores

Primero, querrás generar jugadores.

El código de Python para crear jugadores en el archivo ./generators/authentication_server.py tiene el siguiente aspecto:

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.

Los jugadores que se registraron correctamente se recuperarán mediante una segunda tarea 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á jugadores nuevos durante 30 segundos (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 los jugadores se registraron, quieren empezar a jugar.

El código de Python para crear y cerrar juegos en el archivo ./generators/match_server.py tiene el siguiente aspecto:

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á y cerrará los juegos con una proporción de 2:1 (open:close). 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 jugadores que se registran para jugar y, luego, ejecutas simulaciones para que los jugadores jueguen con el servicio de creación de partidas. Estas simulaciones aprovecharon el framework de Locust Python para emitir solicitudes a nuestros servicios API de REST.

Puedes modificar el tiempo dedicado a crear jugadores y jugar juegos, así como la cantidad de usuarios simultáneos (-u).

Próximos pasos

Después de la simulación, consultarás a Spanner para comprobar varias estadísticas.

7. Recupera estadísticas de juegos

Ahora que hemos simulado que los jugadores pueden registrarse y jugar, debes verificar tus estadísticas.

Para ello, usa la consola de Cloud para enviar solicitudes de consulta a Spanner.

b5e3154c6f7cb0cf.png

Cómo verificar partidos abiertos y cerrados

Un juego cerrado es aquel que tiene la marca de tiempo finished propagada, mientras que un juego abierto tiene el valor finished para ser NULL. Este valor se establece cuando se cierra el juego.

Esta consulta te hará comprobar cuántos partidos 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:

Type

NumGames

Open Games

0

Closed Games

175

Cómo verificar la cantidad de jugadores que juegan en comparación con la cantidad que no juegan

Un jugador está jugando un juego si se configuró su columna current_game. De lo contrario, no está jugando ningún juego.

Por lo tanto, para comparar cuántos jugadores están jugando en ese momento y cuántos no juegan, 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:

Type

NumPlayers

Playing

0

Not Playing

310

Determinar los ganadores principales

Cuando se cierra un juego, se selecciona al azar a uno de los jugadores como ganador. La estadística games_won del jugador aumenta durante el cierre del 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

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

Resumen

En este paso, revisaste varias estadísticas de jugadores y juegos con la consola de Cloud para consultar Spanner.

Próximos pasos

A continuación, es hora de limpiar.

8. Limpieza (opcional)

Para realizar una limpieza, ve a la sección Cloud Spanner de la consola de Cloud y borra la instancia “cloudspanner-gaming” que creamos en el paso del codelab llamado “Configura una instancia de Cloud Spanner”.

9. ¡Felicitaciones!

Felicitaciones, implementaste correctamente un juego de muestra en Spanner

Próximos pasos

En este lab, se presentaron varios temas sobre cómo trabajar con Spanner usando el controlador golang. Debería brindarte una mejor base para comprender conceptos fundamentales, como los siguientes:

  • Diseño de esquemas
  • DML frente a mutaciones
  • Cómo trabajar con Golang

Asegúrate de consultar el codelab Cloud Spanner Game Trading Post para ver otro ejemplo de cómo trabajar con Spanner como backend para tu juego.