Cloud Spanner: primeiros passos com o desenvolvimento de jogos

1. Introdução

O Cloud Spanner é um serviço de banco de dados relacional totalmente gerenciado, escalonável e distribuído globalmente que oferece transações ACID e semântica de SQL sem abrir mão do desempenho e da alta disponibilidade.

Esses recursos fazem do Spanner uma ótima opção para a arquitetura de jogos que querem oferecer uma base global de jogadores ou que se preocupam com a consistência dos dados.

Neste laboratório, você criará dois serviços Go que interagem com um banco de dados regional do Spanner para permitir que os jogadores se inscrevam e comecem a jogar.

413fdd57bb0b68bc.png

Agora você vai gerar dados usando o framework de carregamento Python Locust.io para simular os jogadores se inscrevendo no jogo. Depois você vai consultar o Spanner para determinar quantos jogadores estão jogando e algumas estatísticas sobre os jogos vencidos vs jogos jogados.

Por fim, você vai limpar os recursos criados neste laboratório.

O que você vai criar

Como parte deste laboratório, você vai:

  • Criar uma instância do Spanner
  • Implante um serviço de perfil escrito em Go para processar a inscrição de jogadores
  • Implante um serviço de combinação criado em Go para atribuir jogadores aos jogos, determinar vencedores e atualizar os jogadores estatísticas do jogo.

O que você vai aprender

  • Como configurar uma instância do Cloud Spanner
  • Como criar um banco de dados e um esquema de um jogo
  • Como implantar apps Go para trabalhar com o Cloud Spanner
  • Como gerar dados usando o Locust
  • Como consultar dados no Cloud Spanner para responder a perguntas sobre jogos e jogadores.

O que é necessário

  • Um projeto do Google Cloud conectado a uma conta de faturamento.
  • Um navegador, como o Chrome ou o Firefox

2. Configuração e requisitos

Criar um projeto

Se você ainda não tem uma Conta do Google (Gmail ou Google Apps), crie uma. Faça login no console do Google Cloud Platform ( console.cloud.google.com) e crie um novo projeto.

Se você já tiver um projeto, clique no menu suspenso de seleção no canto superior esquerdo do console:

6c9406d9b014760.png

e clique no botão "NEW PROJECT" na caixa de diálogo exibida para criar um novo projeto:

949d83c8a4ee17d9.png

Se você ainda não tiver um projeto, uma caixa de diálogo como esta será exibida para criar seu primeiro:

870a3cbd6541ee86.png

A caixa de diálogo de criação de projeto subsequente permite que você insira os detalhes do novo projeto:

6a92c57d3250a4b3.png

Lembre-se do código do projeto, um nome exclusivo em todos os projetos do Google Cloud. O nome acima já foi escolhido e não servirá para você. Faremos referência a ele mais adiante neste codelab como PROJECT_ID.

Em seguida, será preciso ativar o faturamento no Developers Console para usar os recursos do Google Cloud e ativar a API Cloud Spanner, caso ainda não tenha feito isso.

15d0ef27a8fbab27.png

A execução por meio deste codelab terá um custo baixo, mas poderá ser mais se você decidir usar mais recursos ou se deixá-los em execução. Consulte a seção "limpeza" no final deste documento. Os preços do Google Cloud Spanner estão documentados neste link.

Novos usuários do Google Cloud Platform estão qualificados para uma avaliação gratuita de US$ 300, o que torna este codelab totalmente gratuito.

Configuração do Google Cloud Shell

Embora o Google Cloud e o Spanner possam ser operados remotamente do seu laptop, neste codelab usaremos o Google Cloud Shell, um ambiente de linha de comando executado no Cloud.

O Cloud Shell é uma máquina virtual com base em Debian que contém todas as ferramentas de desenvolvimento necessárias. Ela oferece um diretório principal persistente de 5 GB, além de ser executada no Google Cloud. Isso aprimora o desempenho e a autenticação da rede. Isso significa que tudo que você precisa para este codelab é um navegador (sim, funciona em um Chromebook).

  1. Para ativar o Cloud Shell no Console do Cloud, basta clicar em Ativar o Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A. O provisionamento e a conexão ao ambiente devem levar apenas alguns instantes.

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

Screen Shot 2017-06-14 às 10.13.43 PM.png

Após se conectar ao Cloud Shell, sua conta já está autenticada e o projeto está configurado com seu PROJECT_ID.

gcloud auth list

Resposta ao comando

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

Resposta ao comando

[core]
project = <PROJECT_ID>

Se, por algum motivo, o projeto não estiver definido, basta emitir o seguinte comando:

gcloud config set project <PROJECT_ID>

Procurando seu PROJECT_ID? Veja qual ID você usou nas etapas de configuração ou procure-o no painel do Console do Cloud:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

O Cloud Shell também define algumas variáveis de ambiente por padrão, o que pode ser útil ao executar comandos futuros.

echo $GOOGLE_CLOUD_PROJECT

Resposta ao comando

<PROJECT_ID>

Fazer o download do código

No Cloud Shell, é possível fazer o download do código para este laboratório. Isto é baseado na versão v0.1.0, portanto, confira esta 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

Resposta ao comando

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

Configurar o gerador de carga Locust

O Locust é um framework de teste de carga em Python útil para testar endpoints da API REST. Neste codelab, temos dois testes de carga diferentes em "geradores". que vamos destacar:

  • authentication_server.py: contém tarefas para criar jogadores e fazer com que um jogador aleatório imite pesquisas de ponto único.
  • match_server.py: contém tarefas para criar e fechar jogos. A criação de jogos atribuirá 100 jogadores aleatórios que não estão jogando. Os jogos de fechamento atualizam as estatísticas de games_played e games_won e permitem que esses jogadores sejam atribuídos a um jogo futuro.

Para executar o Locust no Cloud Shell, você precisa do Python 3.7 ou superior. O Cloud Shell vem com o Python 3.9, portanto, você não precisa fazer nada além de validar a versão:

python -V

Resposta ao comando

Python 3.9.12

Agora é possível instalar os requisitos do Locust.

pip3 install -r requirements.txt

Resposta ao 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

Agora, atualize o PATH para encontrar o binário locust recém-instalado:

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

Resposta ao comando

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

Resumo

Nesta etapa, você configurou seu projeto, se ainda não tinha um, ativou o Cloud Shell e fez o download do código deste laboratório.

Por fim, você vai configurar o Locust para geração de carga mais adiante no laboratório.

A seguir

Em seguida, configure a instância e o banco de dados do Cloud Spanner.

3. Criar uma instância e um banco de dados do Spanner

Criar a instância do Spanner

Nesta etapa, vamos configurar a instância do Spanner para o codelab. Pesquise a entrada do Spanner 1a6580bd3d3e6783.png no menu de navegação superior esquerdo 3129589f7bc9e5ce.png ou procure o Spanner pressionando "/" e digite "Spanner".

36e52f8df8e13b99.png

Em seguida, clique em 95269e75bc8c3e4d.png e preencha o formulário inserindo o nome da instância cloudspanner-gaming, escolhendo uma configuração (selecione uma instância regional como us-central1) e definindo o número de nós. Para este codelab, precisaremos apenas de 500 processing units.

Por fim, mas não menos importante, clique em "Criar" e, em segundos, uma instância do Cloud Spanner está à sua disposição.

4457c324c94f93e6.png

Criar o banco de dados e o esquema

Quando sua instância estiver em execução, você poderá criar o banco de dados. O Spanner permite vários bancos de dados em uma única instância.

O banco de dados é onde você define o esquema. Você também pode controlar quem tem acesso ao banco de dados, definir criptografia personalizada, configurar o otimizador e definir o período de armazenamento.

Em instâncias multirregionais, também é possível configurar o líder padrão. Leia mais sobre bancos de dados no Spanner.

Para este laboratório de programação, você vai criar o banco de dados com opções padrão e fornecer o esquema no momento da criação.

Este laboratório criará duas tabelas: players e games.

77651ac12e47fe2a.png

Os jogadores podem participar de muitos jogos ao longo do tempo, mas apenas de um jogo por vez. Os jogadores também têm as estatísticas como um tipo de dados JSON para acompanhar estatísticas interessantes, como games_played e games_won. Como outras estatísticas podem ser adicionadas mais tarde, essa é uma coluna efetivamente sem esquemas para os jogadores.

Os jogos rastreiam os jogadores que participaram usando o tipo de dados de matriz do Spanner. Os atributos de vencedor e de finalização de um jogo não são preenchidos até que o jogo seja encerrado.

Existe uma chave estrangeira para garantir que o current_game do jogador seja um jogo válido.

Agora clique em "Create Database" para criar o banco de dados. na visão geral da instância:

a820db6c4a4d6f2d.png

Depois preencha os detalhes. As opções importantes são o nome do banco de dados e o dialeto. Neste exemplo, nomeamos o banco de dados sample-game e escolhemos o dialeto SQL padrão do Google.

Para o esquema, copie e cole este DDL na caixa:

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

Em seguida, clique no botão "Criar" e aguarde alguns segundos para que o banco de dados seja criado.

A página de criação do banco de dados ficará assim:

d39d358dc7d32939.png

Agora você precisa definir algumas variáveis de ambiente no Cloud Shell para usar mais adiante no codelab. Portanto, anote o "instance-id" e defina "INSTANCE_ID" e "DATABASE_ID" no Cloud Shell.

f6f98848d3aea9c.png

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

Resumo

Nesta etapa, você criou uma instância do Spanner e o banco de dados sample-game. Você também definiu o esquema usado neste jogo de exemplo.

A seguir

Em seguida, você vai implantar o serviço de perfil para permitir que os jogadores se inscrevam para jogar.

4. Implantar o serviço de perfil

Visão geral do serviço

O serviço de perfil é uma API REST escrita em Go que usa o framework gin.

4fce45ee6c858b3e.png

Nessa API, os jogadores podem se inscrever para jogar. Isso é criado por um simples comando POST que aceita um nome de jogador, e-mail e senha. A senha é criptografada com bcrypt e o hash é armazenado no banco de dados.

E-mail é tratado como um identificador exclusivo, enquanto player_name é usado para fins de exibição do jogo.

No momento, essa API não lida com login, mas a implementação disso pode ser deixada para você como um exercício adicional.

O arquivo ./src/golang/profile-service/main.go para o serviço de perfil expõe dois endpoints principais, da seguinte maneira:

func main() {
   configuration, _ := config.NewConfig()

   router := gin.Default()
   router.SetTrustedProxies(nil)

   router.Use(setSpannerConnection(configuration))

   router.POST("/players", createPlayer)
   router.GET("/players", getPlayerUUIDs)
   router.GET("/players/:id", getPlayerByID)

   router.Run(configuration.Server.URL())
}

E o código desses endpoints será roteado para o 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)
}

Uma das primeiras coisas que o serviço faz é definir a conexão do Spanner. Isso é implementado no nível de serviço para criar o pool de sessões para o serviço.

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 são structs definidos da seguinte maneira:

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

A função para adicionar o jogador utiliza uma inserção DML dentro de uma transação ReadWrite, porque adicionar jogadores é uma única instrução em vez de inserções em lote. A função é semelhante a esta:

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 um jogador com base no UUID dele, uma leitura simples é emitida. Isso recupera playerUUID, player_name, email e stats do player.

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
}

Por padrão, o serviço é configurado usando variáveis de ambiente. Consulte a seção relevante do arquivo ./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
}

Observe que o comportamento padrão é executar o serviço em localhost:8080.

Com essas informações, é hora de executar o serviço.

Executar o serviço de perfil

Execute o serviço usando o comando go. Isso fará o download das dependências e estabelecerá o serviço em execução na porta 8080:

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

Resposta ao 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

Emita um comando curl para testar o serviço:

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

Resposta ao 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"

Resumo

Nesta etapa, você implantou o serviço de perfil que permite que os jogadores se inscrevam para jogar seu jogo e testou o serviço emitindo uma chamada de API POST para criar um novo jogador.

Próximas etapas

Na próxima etapa, você implantará o serviço de combinação.

5. Implantar o serviço de combinação

Visão geral do serviço

O serviço de combinação é uma API REST escrita em Go que aproveita o framework do gin.

9aecd571df0dcd7c.png

Nessa API, os jogos são criados e fechados. Quando um jogo é criado, 10 jogadores que não estão participando dele são atribuídos a ele.

Quando um jogo é fechado, um vencedor é selecionado aleatoriamente, e a pontuação de cada jogador as estatísticas de games_played e games_won foram ajustadas. Além disso, cada jogador é atualizado para indicar que não está mais jogando e, portanto, está disponível para jogar no futuro.

O arquivo ./src/golang/matchmaking-service/main.go para o serviço de combinação segue uma configuração e um código semelhantes ao serviço profile. Portanto, não é repetido aqui. Esse serviço expõe dois endpoints principais da seguinte maneira:

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

Esse serviço fornece um struct Game, bem como os structs Player e PlayerStats reduzidos:

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 criar um jogo, o serviço de combinação seleciona uma seleção aleatória de cem jogadores que não estão jogando no momento.

As mutações do Spanner são escolhidas para criar o jogo e atribuir os jogadores, já que as mutações têm melhor desempenho do que o DML para grandes mudanças.

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

A seleção aleatória de jogadores é feita com SQL usando o recurso TABLESPACE RESERVOIR do GoogleSQL.

Fechar um jogo é um pouco mais complicado. Ele envolve escolher um vencedor aleatório entre os jogadores, marcar a hora em que o jogo terminou e atualizar o tempo de cada jogador estatísticas para games_played e games_won.

Devido a essa complexidade e à quantidade de mudanças, as mutações são escolhidas novamente para fechar o jogo.

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
}

A configuração é processada novamente por meio de variáveis de ambiente, conforme descrito em ./src/golang/matchmaking-service/config/config.go para o serviço.

   // 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 conflitos com o profile-service, esse serviço é executado em localhost:8081 por padrão.

Com essas informações, agora é hora de executar o serviço de combinação.

Executar o serviço de combinação

Execute o serviço usando o comando go. Isso estabelecerá o serviço em execução na porta 8082. Esse serviço tem muitas das mesmas dependências que o profile-service. Portanto, não será feito o download de novas dependências.

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

Resposta ao 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

Criar um jogo

Teste o serviço para criar um jogo. Primeiro, abra um novo terminal no Cloud Shell:

90eceac76a6bb90b.png

Em seguida, emita o seguinte comando curl:

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

Resposta ao 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"

Fechar o jogo

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

Resposta ao 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"

Resumo

Nesta etapa, você implantou o serviço de combinação para criar jogos e atribuir jogadores a eles. Esse serviço também lida com o encerramento de um jogo, o que escolhe um vencedor aleatório e atualiza estatísticas para games_played e games_won.

Próximas etapas

Agora que seus serviços estão funcionando, é hora de fazer com que os usuários se inscrevam e joguem!

6. Começar a jogar

Agora que os serviços de perfil e combinação estão em execução, você pode gerar carga usando os geradores de locust fornecidos.

O Locust oferece uma interface da Web para executar os geradores, mas neste laboratório você vai usar a linha de comando (opção –headless).

Inscrever jogadores

Primeiro, você precisa gerar jogadores.

O código Python para criar jogadores no arquivo ./generators/authentication_server.py tem esta aparência:

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

Nomes de jogadores, e-mails e senhas são gerados aleatoriamente.

Os jogadores que se inscreveram serão recuperados por uma segunda tarefa para gerar a carga de leitura.

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

O comando a seguir chama o arquivo ./generators/authentication_server.py que gerará novos jogadores por 30s (t=30s) com uma simultaneidade de duas linhas de execução (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

Os participantes entram nos jogos

Agora que você inscreveu os jogadores, eles querem começar a jogar.

O código Python para criar e fechar jogos no arquivo ./generators/match_server.py tem esta aparência:

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 esse gerador é executado, ele abre e fecha jogos em uma proporção de 2:1 (aberto:fechado). Este comando vai executar o gerador por 10 segundos (-t=10s):

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

Resumo

Nesta etapa, você simula jogadores se inscrevendo para jogar e, em seguida, executou simulações para os jogadores usando o serviço de combinação. Essas simulações utilizaram o framework do Locust em Python para enviar solicitações aos serviços API REST.

Fique à vontade para modificar o tempo gasto criando jogadores e jogando, além do número de usuários simultâneos (-u).

Próximas etapas

Após a simulação, você vai precisar consultar o Spanner para verificar várias estatísticas.

7. Recuperar estatísticas do jogo

Agora que simulamos jogadores sendo capazes de se inscrever e jogar, você deve verificar suas estatísticas.

Para fazer isso, use o Console do Cloud para enviar solicitações de consulta ao Spanner.

b5e3154c6f7cb0cf.png

Verificar jogos abertos vs. fechados

Um jogo fechado tem o carimbo de data/hora finished preenchido, enquanto um jogo aberto tem o valor finished definido como NULL. Esse valor é definido quando o jogo é fechado.

Esta consulta serve para verificar quantos jogos estão abertos e quantos estão fechados:

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

Verificar a quantidade de jogadores que jogam ou não

Um jogador está jogando quando a coluna current_game está definida. Caso contrário, ele não está jogando.

Assim, para comparar quantos jogadores estão jogando e não jogando no momento, use 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 os vencedores principais

Quando um jogo é encerrado, um dos jogadores é selecionado aleatoriamente para ser o vencedor. A estatística de games_won do jogador é incrementada ao fechar o jogo.

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

Resultado:

playerUUID

stats

07e247c5-f88e-4bca-a7bc-12d2485f2f2b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

09b72595-40af-4406-a000-2fb56c58fe92

{&quot;games_played&quot;:56,&quot;games_won&quot;:1}

1002385b-02a0-462b-a8e7-05c9b27223aa

{&quot;games_played&quot;:66,&quot;games_won&quot;:1}

13ec3770-7ae3-495f-9b53-6322d8e8d6c3

{&quot;games_played&quot;:44,&quot;games_won&quot;:1}

15513852-3f2a-494f-b437-fe7125d15f1b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

17faec64-4f77-475c-8df8-6ab026cf6698

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

1abfcb27-037d-446d-bb7a-b5cd17b5733d

{&quot;games_played&quot;:63,&quot;games_won&quot;:1}

2109a33e-88bd-4e74-a35c-a7914d9e3bde

{&quot;games_played&quot;:56,&quot;games_won&quot;:2}

222e37d9-06b0-4674-865d-a0e5fb80121e

{&quot;games_played&quot;:60,&quot;games_won&quot;:1}

22ced15c-0da6-4fd9-8cb2-1ffd233b3c56

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

Resumo

Nesta etapa, você analisou várias estatísticas de jogadores e jogos usando o Console do Cloud para consultar o Spanner.

Próximas etapas

A seguir, é hora de fazer a limpeza!

8. Limpeza (opcional)

Para fazer a limpeza, acesse a seção Cloud Spanner do console do Cloud e exclua a instância ‘cloudspanner-gaming' que criamos na etapa do codelab chamada "Configurar uma instância do Cloud Spanner".

9. Parabéns!

Parabéns, você implantou um jogo de amostra no Spanner

A seguir

Neste laboratório, apresentamos vários tópicos para trabalhar com o Spanner usando o driver golang. Ele fornece uma base melhor para compreender conceitos críticos, como:

  • Design de esquema
  • DML x mutações
  • Como trabalhar com o Golang

Confira o codelab Postagem de troca de jogos do Cloud Spanner (em inglês) para ver outro exemplo de como trabalhar com o Spanner como back-end para seu jogo.