1. Introdução
O Cloud Spanner é um serviço de banco de dados relacional totalmente gerenciado, horizontalmente escalonável e distribuído globalmente que fornece transações ACID e semântica SQL sem deixar de oferecer desempenho e alta disponibilidade.
Esses recursos tornam o Spanner uma ótima opção na arquitetura de jogos que querem ativar uma base global de jogadores ou se preocupam com a consistência dos dados.
Neste laboratório, você vai 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.

Em seguida, você vai gerar dados usando a estrutura de carga do Python Locust.io para simular jogadores se inscrevendo e jogando. Em seguida, você vai consultar o Spanner para determinar quantos jogadores estão jogando e algumas estatísticas sobre jogos ganhos x jogos disputados.
Por fim, você vai limpar os recursos criados neste laboratório.
O que você vai criar
Neste laboratório, você vai:
- Criar uma instância do Spanner
- Implantar um serviço de perfil escrito em Go para processar a inscrição de jogadores
- Implante um serviço de matchmaking escrito em Go para atribuir jogadores a partidas, determinar vencedores e atualizar as estatísticas de jogo dos jogadores.
O que você vai aprender
- Como configurar uma instância do Cloud Spanner
- Como criar um banco de dados e um esquema de 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
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:

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

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

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

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ê. Ele será indicado 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.

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 sem custo financeiro de US$300, o que torna este codelab totalmente sem custo financeiro.
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).
- Para ativar o Cloud Shell no Console do Cloud, basta clicar em Ativar o Cloud Shell
. Leva apenas alguns instantes para provisionar e se conectar ao ambiente.


Depois de se conectar ao Cloud Shell, sua conta já estará autenticada e o projeto estará 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>
Quer encontrar seu PROJECT_ID? Veja qual ID você usou nas etapas de configuração ou procure-o no painel do Console do Cloud:

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, faça o download do código deste laboratório. Isso se baseia na versão v0.1.0. Portanto, confira essa 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 do Locust
O Locust é um framework de teste de carga em Python útil para testar endpoints de API REST. Neste codelab, temos dois testes de carga diferentes no diretório "generators", que vamos destacar:
- authentication_server.py: contém tarefas para criar jogadores e receber um jogador aleatório para imitar pesquisas de ponto único.
- match_server.py: contém tarefas para criar e encerrar jogos. Ao criar jogos, 100 jogadores aleatórios que não estão jogando no momento são atribuídos. Ao fechar os jogos, as estatísticas "games_played" e "games_won" são atualizadas, e os jogadores podem ser atribuídos a uma partida futura.
Para executar o Locust no Cloud Shell, você precisa do Python 3.7 ou de uma versão mais recente. O Cloud Shell vem com o Python 3.9. Portanto, basta validar a versão:
python -V
Resposta ao comando
Python 3.9.12
Agora, instale 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 que o binário locust recém-instalado possa ser encontrado:
PATH=~/.local/bin":$PATH"
which locust
Resposta ao comando
/home/<user>/.local/bin/locust
Resumo
Nesta etapa, você configurou seu projeto, caso ainda não tivesse um, ativou o Cloud Shell e baixou o código para este 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
no menu de navegação superior esquerdo
ou procure o Spanner pressionando "/" e digite "Spanner".

Depois, clique em
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 defina o número de nós. Neste codelab, vamos precisar 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.

Criar o banco de dados e o esquema
Depois que a 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, configurar a 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.
Neste codelab, você vai criar o banco de dados com opções padrão e fornecer o esquema no momento da criação.
Este laboratório vai criar duas tabelas: players e games.

Os jogadores podem participar de vários jogos ao longo do tempo, mas apenas um por vez. Os jogadores também têm 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 sem esquema para jogadores.
Jogos: acompanham os jogadores que participaram usando o tipo de dados ARRAY do Spanner. Os atributos de vencedor e concluído de um jogo não são preenchidos até que ele seja encerrado.
Há uma chave estrangeira para garantir que o current_game do jogador seja um jogo válido.
Agora, crie o banco de dados clicando em "Criar banco de dados" na visão geral da instância:

Em seguida, 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 como sample-game e escolhemos o dialeto do 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 de banco de dados terá esta aparência:

Agora, defina algumas variáveis de ambiente no Cloud Shell para usar mais tarde no codelab. Portanto, anote o instance-id e defina INSTANCE_ID e DATABASE_ID no Cloud Shell.

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 por este jogo de exemplo.
A seguir
Em seguida, implante o serviço de perfil para permitir que os jogadores se inscrevam para jogar.
4. Implante 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.

Nessa API, os jogadores podem se inscrever para jogar. Ele é criado por um comando POST simples que aceita um nome de jogador, e-mail e senha. A senha é criptografada com bcrypt, e o hash é armazenado no banco de dados.
O e-mail é tratado como um identificador exclusivo, enquanto o player_name é usado para fins de exibição no jogo.
No momento, essa API não processa o login, mas você pode implementar isso como um exercício adicional.
O arquivo ./src/golang/profile-service/main.go do 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())
}
O código desses endpoints será encaminhado 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 do serviço para criar o pool de sessões do 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()
}
}
As structs Player e PlayerStats são definidas 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 usa uma inserção de DML em uma transação ReadWrite, porque a adição de jogadores é uma única instrução, em vez de inserções em lote. A função tem esta aparência:
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 o playerUUID, player_name, email e as estatísticas do jogador.
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
}
Você pode ver 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 vai baixar as 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
Teste o serviço emitindo um 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"}'
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, e testou o serviço emitindo uma chamada de API POST para criar um novo jogador.
Próximas etapas
Na próxima etapa, você vai implantar o serviço de matchmaking.
5. Implantar o serviço de encontros
Visão geral do serviço
O serviço de matchmaking é uma API REST escrita em Go que usa o framework gin.

Nessa API, os jogos são criados e encerrados. Quando um jogo é criado, 10 jogadores que não estão jogando no momento são atribuídos a ele.
Quando um jogo é encerrado, um vencedor é selecionado aleatoriamente, e as estatísticas de cada jogador em jogos_jogados e jogos_vencidos são ajustadas. Além disso, cada jogador é atualizado para indicar que não está mais jogando e, portanto, está disponível para jogar partidas futuras.
O arquivo ./src/golang/matchmaking-service/main.go do serviço de matchmaking segue uma configuração e um código semelhantes ao serviço profile, então não é repetido aqui. Esse serviço expõe dois endpoints principais da seguinte forma:
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 uma struct Game, além de structs Player e PlayerStats reduzidas:
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 uma partida, o serviço de combinação seleciona aleatoriamente 100 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 elas têm uma performance melhor do que a 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. Isso envolve escolher um vencedor aleatório entre os jogadores, marcar o horário em que o jogo termina e atualizar as estatísticas de cada jogador em games_played e games_won.
Devido a essa complexidade e à quantidade de mudanças, as mutações são escolhidas novamente para encerrar 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 é novamente processada por 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 matchmaking.
Executar o serviço de combinação de perfis
Execute o serviço usando o comando go. Isso vai estabelecer o serviço em execução na porta 8082. Esse serviço tem muitas das mesmas dependências do profile-service, então novas dependências não serão baixadas.
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:

Em seguida, execute o comando curl a seguir:
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 matchmaking para criar jogos e atribuir jogadores a eles. Esse serviço também processa o encerramento de um jogo, que escolhe um vencedor aleatório e atualiza as estatísticas de todos os jogadores para games_played e games_won.
Próximas etapas
Agora que seus serviços estão em execução, é hora de fazer com que os jogadores se inscrevam e joguem!
6. Começar a jogar
Agora que os serviços de perfil e matchmaking 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, gere jogadores.
O código Python para criar jogadores no arquivo ./generators/authentication_server.py é assim:
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'")
Os nomes de jogadores, e-mails e senhas são gerados aleatoriamente.
Os jogadores inscritos com sucesso serão recuperados por uma segunda tarefa para gerar 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 vai gerar novos jogadores por 30 segundos (t=30s) com uma simultaneidade de duas linhas de execução por 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
Os jogadores participam dos jogos
Agora que você tem jogadores inscritos, eles querem começar a jogar!
O código Python para criar e fechar jogos no arquivo ./generators/match_server.py é assim:
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 (abrir:fechar). Esse 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ê simulou jogadores se inscrevendo para jogar e depois executou simulações para que eles jogassem usando o serviço de pareamento. Essas simulações usaram o framework Python Locust para emitir solicitações à API REST dos nossos serviços.
Modifique o tempo gasto criando jogadores e jogando, bem como o número de usuários simultâneos (-u).
Próximas etapas
Após a simulação, consulte o Spanner para verificar várias estatísticas.
7. Recuperar estatísticas do jogo
Agora que simulamos jogadores se inscrevendo e jogando, confira suas estatísticas.
Para fazer isso, use o console do Cloud para emitir solicitações de consulta ao Spanner.

Verificar jogos abertos e fechados
Um jogo concluído é aquele em que a marcação de tempo finished está preenchida, enquanto um jogo em aberto tem finished como NULL. Esse valor é definido quando o jogo é fechado.
Assim, essa consulta permite 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:
|
|
|
|
|
|
Verificar a quantidade de jogadores que estão jogando e os que não estão
Um jogador está jogando se a coluna current_game estiver definida. Caso contrário, eles não estão jogando no momento.
Para comparar quantos jogadores estão jogando e quantos não estão, 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:
|
|
|
|
|
|
Determinar os principais vencedores
Quando um jogo é encerrado, um dos jogadores é selecionado aleatoriamente como vencedor. A estatística games_won desse jogador é incrementada ao final da partida.
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 | {"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} |
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
Agora é hora de limpar!
8. Limpeza (opcional)
Para limpar, basta acessar a seção Cloud Spanner do console do Cloud e excluir 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, você aprendeu sobre vários tópicos de trabalho com o Spanner usando o driver golang. Ele vai ajudar você a entender melhor conceitos importantes, como:
- Design de esquema
- DML x mutações
- Como trabalhar com Golang
Confira o codelab Cloud Spanner Game Trading Post para outro exemplo de como trabalhar com o Spanner como back-end do seu jogo.