1. Introduction
Cloud Spanner est un service de base de données relationnelle entièrement géré, distribué à l'échelle mondiale et évolutif horizontalement qui fournit des transactions ACID et une sémantique SQL sans compromis sur les performances et la haute disponibilité.
Ces fonctionnalités font de Spanner un excellent choix pour l'architecture des jeux qui souhaitent activer une base de joueurs mondiale ou qui se soucient de la cohérence des données.
Dans cet atelier, vous allez créer deux services Go qui interagissent avec une base de données Spanner régionale pour permettre aux joueurs de s'inscrire et de commencer à jouer.

Vous allez ensuite générer des données à l'aide du framework de charge Python Locust.io pour simuler des joueurs s'inscrivant et jouant au jeu. Vous interrogerez ensuite Spanner pour déterminer le nombre de joueurs et obtenir des statistiques sur les matchs gagnés par rapport aux matchs joués.
Enfin, vous nettoierez les ressources créées dans cet atelier.
Ce que vous allez faire
Au cours de cet atelier, vous allez :
- Créer une instance Spanner
- Déployer un service de profil écrit en Go pour gérer l'inscription des joueurs
- Déployez un service de mise en correspondance écrit en Go pour attribuer des joueurs à des parties, déterminer les gagnants et mettre à jour les statistiques de jeu des joueurs.
Points abordés
- Configurer une instance Cloud Spanner
- Créer une base de données et un schéma de jeu
- Déployer des applications Go pour qu'elles fonctionnent avec Cloud Spanner
- Générer des données à l'aide de Locust
- Comment interroger des données dans Cloud Spanner pour répondre à des questions sur les jeux et les joueurs.
Prérequis
2. Préparation
Créer un projet
Si vous ne possédez pas encore de compte Google (Gmail ou Google Apps), vous devez en créer un. Connectez-vous à la console Google Cloud Platform ( console.cloud.google.com) et créez un projet.
Si vous avez déjà un projet, cliquez sur le menu déroulant de sélection du projet dans l'angle supérieur gauche de la console :

Cliquez ensuite sur le bouton "NEW PROJECT" (NOUVEAU PROJET) dans la boîte de dialogue qui s'affiche pour créer un projet :

Si vous n'avez pas encore de projet, une boîte de dialogue semblable à celle-ci apparaîtra pour vous permettre d'en créer un :

La boîte de dialogue de création de projet suivante vous permet de saisir les détails de votre nouveau projet :

Notez l'ID du projet. Il s'agit d'un nom unique pour tous les projets Google Cloud, ce qui implique que le nom ci-dessus n'est plus disponible pour vous… Désolé ! Il sera désigné par le nom PROJECT_ID tout au long de cet atelier de programmation.
Ensuite, si ce n'est pas déjà fait, vous devez activer la facturation dans Developers Console afin de pouvoir utiliser les ressources Google Cloud puis activer l'API Cloud Spanner.

Suivre cet atelier de programmation ne devrait pas vous coûter plus d'un euro. Cependant, cela peut s'avérer plus coûteux si vous décidez d'utiliser davantage de ressources ou si vous n'interrompez pas les ressources (voir la section "Effectuer un nettoyage" à la fin du présent document). Les tarifs de Google Cloud Spanner sont décrits sur cette page.
Les nouveaux utilisateurs de Google Cloud Platform peuvent bénéficier d'un essai sans frais avec 300$de crédits afin de suivre sans frais le présent atelier.
Configuration de Google Cloud Shell
Bien que Google Cloud et Spanner puissent être utilisés à distance depuis votre ordinateur portable, nous allons utiliser Google Cloud Shell pour cet atelier de programmation, un environnement de ligne de commande exécuté dans le cloud.
Cette machine virtuelle basée sur Debian contient tous les outils de développement dont vous aurez besoin. Elle intègre un répertoire d'accueil persistant de 5 Go et s'exécute sur Google Cloud, ce qui améliore nettement les performances du réseau et l'authentification. Cela signifie que tout ce dont vous avez besoin pour cet atelier de programmation est un navigateur (oui, tout fonctionne sur un Chromebook).
- Pour activer Cloud Shell à partir de la console Cloud, cliquez simplement sur Activer Cloud Shell
(le provisionnement de l'environnement et la connexion ne devraient prendre que quelques minutes).


Une fois connecté à Cloud Shell, vous êtes en principe authentifié, et le projet est déjà défini avec votre ID_PROJET.
gcloud auth list
Résultat de la commande
Credentialed accounts:
- <myaccount>@<mydomain>.com (active)
gcloud config list project
Résultat de la commande
[core]
project = <PROJECT_ID>
Si, pour une raison quelconque, le projet n'est pas défini, exécutez simplement la commande suivante :
gcloud config set project <PROJECT_ID>
Vous ne connaissez pas votre ID de projet ? Vérifiez l'ID que vous avez utilisé pendant les étapes de configuration ou recherchez-le dans le tableau de bord Cloud Console :

Par défaut, Cloud Shell définit certaines variables d'environnement qui pourront s'avérer utiles pour exécuter certaines commandes dans le futur.
echo $GOOGLE_CLOUD_PROJECT
Résultat de la commande
<PROJECT_ID>
Télécharger le code
Dans Cloud Shell, vous pouvez télécharger le code de cet atelier. Cette opération est basée sur la version v0.1.0. Vérifiez donc cette balise :
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
Résultat de la commande
Cloning into 'spanner-gaming-sample'...
*snip*
Switched to a new branch 'v0.1.0-branch'
Configurer le générateur de charge Locust
Locust est un framework de test de charge Python utile pour tester les points de terminaison de l'API REST. Dans cet atelier de programmation, nous allons mettre en avant deux tests de charge différents dans le répertoire "generators" :
- authentication_server.py : contient des tâches permettant de créer des joueurs et d'obtenir un joueur aléatoire pour imiter les recherches ponctuelles.
- match_server.py : contient des tâches permettant de créer et de fermer des jeux. Lorsque vous créez des parties, 100 joueurs aléatoires qui ne sont pas en train de jouer sont attribués. La clôture des parties met à jour les statistiques "Parties jouées" et "Parties gagnées", et permet d'attribuer ces joueurs à une prochaine partie.
Pour exécuter Locust dans Cloud Shell, vous devez disposer de Python 3.7 ou version ultérieure. Cloud Shell est fourni avec Python 3.9. Il vous suffit donc de valider la version :
python -V
Résultat de la commande
Python 3.9.12
Vous pouvez maintenant installer les exigences pour Locust.
pip3 install -r requirements.txt
Résultat de la commande
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
Mettez à jour le chemin d'accès pour que le binaire locust nouvellement installé puisse être trouvé :
PATH=~/.local/bin":$PATH"
which locust
Résultat de la commande
/home/<user>/.local/bin/locust
Résumé
Au cours de cette étape, vous avez configuré votre projet si vous n'en aviez pas déjà un, activé Cloud Shell et téléchargé le code de cet atelier.
Enfin, vous configurerez Locust pour la génération de charge plus tard dans l'atelier.
Étape suivante
Vous allez ensuite configurer l'instance et la base de données Cloud Spanner.
3. Créer une instance et une base de données Spanner
Créer l'instance Spanner
Dans cette étape, nous allons configurer notre instance Spanner pour l'atelier de programmation. Recherchez l'entrée Spanner
dans le menu principal en haut à gauche
ou recherchez Spanner en appuyant sur "/" et saisissez "Spanner"

Cliquez ensuite sur
et remplissez le formulaire en renseignant le nom d'instance cloudspanner-gaming, en choisissant une configuration (sélectionnez une instance régionale telle que us-central1), puis en définissant le nombre de nœuds. Pour cet atelier de programmation, nous n'aurons besoin que de 500 processing units.
Dernière étape mais pas des moindres, cliquez sur "Créer". L'instance Cloud Spanner sera prête en quelques secondes.

Créer la base de données et le schéma
Une fois votre instance en cours d'exécution, vous pouvez créer la base de données. Spanner permet d'avoir plusieurs bases de données sur une même instance.
C'est dans la base de données que vous définissez votre schéma. Vous pouvez également contrôler qui a accès à la base de données, configurer un chiffrement personnalisé, configurer l'optimiseur et définir la période de conservation.
Sur les instances multirégionales, vous pouvez également configurer le leader par défaut. En savoir plus sur les bases de données dans Spanner
Pour cet atelier de programmation, vous allez créer la base de données avec les options par défaut et fournir le schéma au moment de la création.
Dans cet atelier, vous allez créer deux tables : players et games.

Les joueurs peuvent participer à de nombreux jeux au fil du temps, mais à un seul à la fois. Les joueurs disposent également de statistiques en tant que type de données JSON pour suivre des statistiques intéressantes telles que games_played et games_won. Comme d'autres statistiques pourront être ajoutées ultérieurement, il s'agit en fait d'une colonne sans schéma pour les joueurs.
Les jeux gardent la trace des joueurs qui ont participé à l'aide du type de données ARRAY de Spanner. Les attributs "winner" et "finished" d'un match ne sont renseignés que lorsque le match est terminé.
Une clé étrangère permet de s'assurer que la current_game du joueur est un jeu valide.
Créez ensuite la base de données en cliquant sur "Créer une base de données" dans la présentation de l'instance :

Ensuite, saisissez les informations. Les options importantes sont le nom de la base de données et le dialecte. Dans cet exemple, nous avons nommé la base de données sample-game et choisi le dialecte SQL standard de Google.
Pour le schéma, copiez et collez cette instruction DDL dans la zone :
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);
Cliquez ensuite sur le bouton de création et attendez quelques secondes que votre base de données soit créée.
La page de création de la base de données devrait se présenter comme suit :

Vous devez maintenant définir des variables d'environnement dans Cloud Shell, qui seront utilisées plus tard dans l'atelier de programmation. Notez l'instance-id, puis définissez INSTANCE_ID et DATABASE_ID dans Cloud Shell.

export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
Résumé
Lors de cette étape, vous avez créé une instance Spanner et la base de données sample-game. Vous avez également défini le schéma utilisé par cet exemple de jeu.
Étape suivante
Vous allez ensuite déployer le service de profil pour permettre aux joueurs de s'inscrire et de jouer au jeu.
4. Déployer le service de profil
Présentation générale du service
Le service de profil est une API REST écrite en Go qui utilise le framework Gin.

Dans cette API, les joueurs peuvent s'inscrire pour jouer à des jeux. Il est créé par une simple commande POST qui accepte le nom, l'adresse e-mail et le mot de passe d'un joueur. Le mot de passe est chiffré avec bcrypt et le hachage est stocké dans la base de données.
L'adresse e-mail est traitée comme un identifiant unique, tandis que le nom du joueur est utilisé à des fins d'affichage dans le jeu.
Cette API ne gère pas la connexion pour le moment, mais vous pouvez l'implémenter vous-même comme exercice supplémentaire.
Le fichier ./src/golang/profile-service/main.go du service de profil expose deux points de terminaison principaux :
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())
}
Le code de ces points de terminaison sera routé vers le modèle 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)
}
L'une des premières choses que fait le service est de définir la connexion Spanner. Cette opération est implémentée au niveau du service pour créer le pool de sessions pour le service.
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 et PlayerStats sont des structs définis comme suit :
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 fonction permettant d'ajouter le joueur utilise une insertion LMD dans une transaction ReadWrite, car l'ajout de joueurs est une instruction unique plutôt que des insertions par lot. La fonction se présente comme suit :
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
}
Pour récupérer un joueur en fonction de son UUID, une simple lecture est émise. Cela permet de récupérer l'UUID du joueur, son nom, son adresse e-mail et ses statistiques.
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
}
Par défaut, le service est configuré à l'aide de variables d'environnement. Consultez la section correspondante du fichier ./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
}
Vous pouvez constater que le comportement par défaut consiste à exécuter le service sur localhost:8080.
Maintenant que vous disposez de ces informations, vous pouvez exécuter le service.
Exécuter le service de profil
Exécutez le service à l'aide de la commande go. Cela téléchargera les dépendances et établira le service s'exécutant sur le port 8080 :
cd ~/spanner-gaming-sample/src/golang/profile-service
go run . &
Résultat de la commande :
[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
Testez le service en exécutant une commande 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"}'
Résultat de la commande :
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"
Résumé
Au cours de cette étape, vous avez déployé le service de profil qui permet aux joueurs de s'inscrire pour jouer à votre jeu. Vous avez également testé le service en envoyant un appel d'API POST pour créer un joueur.
Étapes suivantes
À l'étape suivante, vous allez déployer le service de matchmaking.
5. Déployer le service de mise en relation
Présentation générale du service
Le service de mise en relation est une API REST écrite en Go qui utilise le framework Gin.

Dans cette API, les jeux sont créés et fermés. Lorsqu'un jeu est créé, 10 joueurs qui ne sont pas en train de jouer sont attribués au jeu.
Lorsqu'une partie est fermée, un gagnant est sélectionné au hasard et les statistiques de chaque joueur pour games_played et games_won sont ajustées. De plus, chaque joueur est mis à jour pour indiquer qu'il ne joue plus et qu'il est donc disponible pour jouer à d'autres jeux.
Le fichier ./src/golang/matchmaking-service/main.go du service de matchmaking suit une configuration et un code similaires à ceux du service profile. Il n'est donc pas répété ici. Ce service expose deux points de terminaison principaux :
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())
}
Ce service fournit une structure Game, ainsi que des structures Player et PlayerStats simplifiées :
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"`
}
Pour créer une partie, le service de mise en correspondance sélectionne au hasard 100 joueurs qui ne sont pas en train de jouer.
Les mutations Spanner sont choisies pour créer le jeu et attribuer les joueurs, car elles sont plus performantes que le LMD pour les modifications importantes.
// 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 sélection aléatoire des joueurs est effectuée avec SQL à l'aide de la fonctionnalité TABLESPACE RESERVOIR de GoogleSQL.
Fermer un jeu est un peu plus compliqué. Il s'agit de choisir un gagnant au hasard parmi les joueurs, de marquer l'heure à laquelle la partie est terminée et de mettre à jour les statistiques de chaque joueur pour games_played et games_won.
En raison de cette complexité et du nombre de changements, les mutations sont à nouveau choisies pour terminer le jeu.
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 configuration est à nouveau gérée par le biais de variables d'environnement, comme décrit dans ./src/golang/matchmaking-service/config/config.go pour le service.
// 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")
Pour éviter les conflits avec le service de profil, ce service s'exécute par défaut sur localhost:8081.
Maintenant que vous disposez de ces informations, il est temps d'exécuter le service de matchmaking.
Exécuter le service de mise en relation
Exécutez le service à l'aide de la commande go. Le service s'exécutera sur le port 8082. Ce service présente de nombreuses dépendances identiques à celles du service de profil. Par conséquent, les nouvelles dépendances ne seront pas téléchargées.
cd ~/spanner-gaming-sample/src/golang/matchmaking-service
go run . &
Résultat de la commande :
[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
Créer un jeu
Testez le service pour créer un jeu. Commencez par ouvrir un nouveau terminal dans Cloud Shell :

Exécutez ensuite la commande curl suivante :
curl http://localhost:8081/games/create \
--include \
--header "Content-Type: application/json" \
--request "POST"
Résultat de la commande :
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"
Quitter le jeu
curl http://localhost:8081/games/close \
--include \
--header "Content-Type: application/json" \
--data '{"gameUUID": "f45b0f7f-405b-4e67-a3b8-a624e990285d"}' \
--request "PUT"
Résultat de la commande :
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"
Résumé
Au cours de cette étape, vous avez déployé le service de matchmaking pour gérer la création de jeux et l'attribution de joueurs à ces jeux. Ce service gère également la fin d'une partie, en choisissant un gagnant au hasard et en mettant à jour les statistiques de tous les joueurs pour games_played et games_won.
Étapes suivantes
Maintenant que vos services sont en cours d'exécution, il est temps d'inciter les joueurs à s'inscrire et à jouer !
6. Jouer
Maintenant que les services de profil et de matchmaking sont en cours d'exécution, vous pouvez générer de la charge à l'aide des générateurs Locust fournis.
Locust propose une interface Web pour exécuter les générateurs, mais dans cet atelier, vous utiliserez la ligne de commande (option –headless).
Inscrire des joueurs
Commencez par générer des joueurs.
Le code Python permettant de créer des joueurs dans le fichier ./generators/authentication_server.py se présente comme suit :
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'")
Les noms de joueurs, les adresses e-mail et les mots de passe sont générés de manière aléatoire.
Les joueurs inscrits seront récupérés par une deuxième tâche pour générer une charge de lecture.
@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]")
La commande suivante appelle le fichier ./generators/authentication_server.py qui générera de nouveaux joueurs pendant 30 secondes (t=30s) avec une simultanéité de deux threads à la fois (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
Les joueurs rejoignent les parties
Maintenant que des joueurs se sont inscrits, ils veulent commencer à jouer !
Le code Python permettant de créer et de fermer des jeux dans le fichier ./generators/match_server.py se présente comme suit :
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)
Lorsque ce générateur est exécuté, il ouvre et ferme les jeux dans un rapport de 2:1 (ouvert:fermé). Cette commande exécutera le générateur pendant 10 secondes (-t=10s) :
locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s
Résumé
Au cours de cette étape, vous avez simulé l'inscription de joueurs pour jouer à des jeux, puis vous avez exécuté des simulations pour que les joueurs puissent jouer à des jeux à l'aide du service de matchmaking. Ces simulations ont utilisé le framework Python Locust pour envoyer des requêtes à l'API REST de nos services.
N'hésitez pas à modifier le temps passé à créer des joueurs et à jouer à des jeux, ainsi que le nombre d'utilisateurs simultanés (-u).
Étapes suivantes
Après la simulation, vous devrez vérifier diverses statistiques en interrogeant Spanner.
7. Récupérer les statistiques de jeu
Maintenant que nous avons simulé des joueurs capables de s'inscrire et de jouer à des jeux, vous devez vérifier vos statistiques.
Pour ce faire, utilisez la console Cloud pour envoyer des requêtes à Spanner.

Vérifier les jeux ouverts et fermés
Un match terminé est un match dont le champ finished est renseigné avec un code temporel, tandis qu'un match en cours aura la valeur NULL dans le champ finished. Cette valeur est définie lorsque le jeu est fermé.
Cette requête vous permettra de vérifier le nombre de parties ouvertes et fermées :
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
)
Résultat :
|
|
|
|
|
|
Vérifier le nombre de joueurs qui jouent et ceux qui ne jouent pas
Un joueur est considéré comme jouant à un jeu si sa colonne current_game est définie. Sinon, cela signifie qu'il ne joue pas à un jeu pour le moment.
Pour comparer le nombre de joueurs qui jouent actuellement et ceux qui ne jouent pas, utilisez la requête suivante :
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
)
Résultat :
|
|
|
|
|
|
Déterminer les grands gagnants
Lorsqu'un jeu est fermé, l'un des joueurs est sélectionné au hasard comme gagnant. La statistique games_won de ce joueur est incrémentée à la fin de la partie.
SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;
Résultat :
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} |
Résumé
Dans cette étape, vous avez examiné diverses statistiques sur les joueurs et les jeux en utilisant la console Cloud pour interroger Spanner.
Étapes suivantes
Il est maintenant temps de nettoyer !
8. Nettoyage (facultatif)
Pour effectuer le nettoyage, il vous suffit d'accéder à la section Cloud Spanner de la console Cloud et de supprimer l'instance cloudspanner-gaming que nous avons créée à l'étape "Configurer une instance Cloud Spanner".
9. Félicitations !
Félicitations, vous avez déployé un exemple de jeu sur Spanner.
Étape suivante
Dans cet atelier, vous avez découvert différents aspects de l'utilisation de Spanner avec le pilote Go. Il devrait vous permettre de mieux comprendre les concepts essentiels suivants :
- Conception de schémas
- LMD vs mutations
- Utiliser Golang
N'oubliez pas de consulter l'atelier de programmation Cloud Spanner Game Trading Post pour obtenir un autre exemple d'utilisation de Spanner comme backend pour votre jeu.