Premiers pas avec le développement de jeux avec Cloud Spanner

1. Introduction

Cloud Spanner est un service de base de données relationnelle entièrement géré, évolutif horizontalement et distribué à l'échelle mondiale. Il fournit des transactions ACID et une sémantique SQL sans faire de compromis sur les performances et la haute disponibilité.

Grâce à ces fonctionnalités, Spanner est parfaitement adapté à l'architecture des jeux qui souhaitent toucher une base de joueurs mondiale ou qui sont soucieux 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.

413fdd57bb0b68bc.png

Vous allez maintenant générer des données à l'aide du framework de chargement Python Locust.io pour simuler l'inscription des joueurs et leur partie. Ensuite, vous interrogerez Spanner pour déterminer le nombre de joueurs en train de jouer et obtenir des statistiques les matchs gagnés ou les matchs joués.

Enfin, vous allez nettoyer les ressources que vous avez créées dans cet atelier.

Ce que vous allez faire

Au cours de cet atelier, vous allez:

  • Créer une instance Spanner
  • Déployez un service Profile écrit en Go pour gérer l'inscription des joueurs
  • Déployez un service de mise en correspondance écrit en Go pour affecter des joueurs à des jeux, désigner les vainqueurs et mettre à jour les données des joueurs les statistiques de jeu.

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 avec Locust
  • Interroger des données dans Cloud Spanner pour répondre à des questions concernant les jeux et les joueurs

Prérequis

  • Un projet Google Cloud associé à un compte de facturation
  • Un navigateur (Chrome ou Firefox, par exemple).

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 :

6c9406d9b014760.png

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

949d83c8a4ee17d9.png

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 :

870a3cbd6541ee86.png

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

6a92c57d3250a4b3.png

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" dans la suite 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.

15d0ef27a8fbab27.png

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 gratuit avec 300 $ de crédits afin de suivre gratuitement 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).

  1. Pour activer Cloud Shell depuis la console Cloud, il vous suffit de cliquer sur Activer Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A. Le provisionnement et la connexion à l'environnement ne devraient pas prendre plus de quelques minutes.

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

Capture d'écran du 2017-06-14 à 10.13.43 PM.png

Une fois connecté à Cloud Shell, vous êtes en principe authentifié, et le projet est déjà défini sur 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 cherchez 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 :

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

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. Ce tag étant basé sur la version v0.1.0, vérifiez ce 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

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 tests de charge Python qui permet de tester les points de terminaison de l'API REST. Dans cet atelier de programmation, nous proposons deux tests de charge différents dans les générateurs que nous allons souligner:

  • authentication_server.py: contient les tâches permettant de créer des joueurs et d'amener un joueur aléatoire à imiter des recherches à un seul point.
  • match_server.py: contient les tâches permettant de créer des jeux et de fermer les jeux. La création de jeux assignera 100 joueurs aléatoires qui ne jouent pas actuellement à un jeu. La fermeture des jeux mettra à jour les statistiques games_played et games_won, et permettra à ces joueurs d'être affectés à une partie future.

Pour exécuter Locust dans Cloud Shell, vous devez disposer de Python 3.7 ou d'une version ultérieure. Cloud Shell est fourni avec Python 3.9. Vous n'avez donc qu'à valider la version:

python -V

Résultat de la commande

Python 3.9.12

Vous pouvez maintenant installer la configuration requise 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

À présent, mettez à jour le PATH afin que le binaire locust nouvellement installé soit disponible:

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.

Pour terminer, vous configurerez Locust pour la génération de charge dans la suite de l'atelier.

Étape suivante

Vous allez maintenant 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

Au cours de cette étape, nous avons configuré notre instance Spanner pour cet atelier de programmation. Recherchez l'entrée Spanner 1a6580bd3d3e6783.pngdans le menu principal en haut à gauche 3129589f7bc9e5ce.png ou recherchez Spanner en appuyant sur "/" et saisissez "Spanner"

36e52f8df8e13b99.png

Ensuite, cliquez sur 95269e75bc8c3e4d.png et remplissez le formulaire en saisissant le nom d'instance cloudspanner-gaming pour votre instance, en choisissant une configuration (sélectionnez une instance régionale comme us-central1) et en définissant le nombre de nœuds. Pour cet atelier de programmation, nous n'avons 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.

4457c324c94f93e6.png

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'utiliser 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, mettre en place un chiffrement personnalisé, configurer l'optimiseur et définir la durée de conservation.

Sur les instances multirégionales, vous pouvez également configurer l'instance principale par défaut. Apprenez-en plus sur les bases de données sur Spanner.

Dans 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 (joueurs) et games (jeux).

77651ac12e47fe2a.png

Les joueurs peuvent participer à de nombreux jeux sur une période, mais un seul à la fois. Les joueurs disposent également de stats dans le type de données JSON pour suivre les statistiques intéressantes comme games_played et games_won. Étant donné que d'autres statistiques peuvent être ajoutées ultérieurement, il s'agit d'une colonne sans schéma pour les joueurs.

Les jeux permettent d'effectuer le suivi des joueurs qui ont participé à l'aide du type de données ARRAY de Spanner. Les attributs gagnant et terminé d'un jeu ne sont pas renseignés tant que le jeu n'est pas fermé.

Il existe une clé étrangère pour s'assurer que le jeu current_game du joueur est un jeu valide.

À présent, créez la base de données en cliquant sur "Créer une base de données". dans la vue d'ensemble de l'instance:

a820db6c4a4d6f2d.png

puis renseignez les détails. 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 ce LDD 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 "Créer" et attendez quelques secondes que votre base de données soit créée.

La page "Créer une base de données" doit se présenter comme suit:

d39d358dc7d32939.png

Vous devez maintenant définir dans Cloud Shell des variables d'environnement que vous utiliserez plus tard dans l'atelier de programmation. Notez donc l'ID de l'instance, et définissez les valeurs INSTANCE_ID et DATABASE_ID dans Cloud Shell.

f6f98848d3aea9c.png

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

Résumé

Au cours 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é dans cet exemple de jeu.

Étape suivante

Vous allez maintenant déployer le service de profil pour permettre aux joueurs de s'inscrire pour 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 exploite le framework gin.

4fce45ee6c858b3e.png

Dans cette API, les joueurs peuvent s'inscrire pour jouer. Celui-ci est créé par une simple commande POST qui accepte un nom de joueur, une adresse e-mail et un mot de passe. 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 (player_name) est utilisé à des fins d'affichage dans le jeu.

Pour le moment, cette API ne gère pas la connexion, mais vous pouvez vous charger de sa mise en œuvre en tant qu'exercice supplémentaire.

Le fichier ./src/golang/profile-service/main.go du service de profil expose deux points de terminaison principaux comme suit:

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 acheminé 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)
}

La première chose que fait ce service est de définir la connexion Spanner. Ceci est mis en œuvre 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 structures définies 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 lecteur exploite une insertion LMD dans une transaction ReadWrite, car l'ajout de lecteurs est une instruction unique plutôt que des insertions par lots. 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 à partir de son UUID, une lecture simple est émise. Cette opération permet de récupérer les éléments playerUUID, player_name, email et stats.

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 appropriée dans le 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.

Avec ces informations, il est temps d'exécuter le service.

Exécuter le service de profil

Exécutez le service à l'aide de la commande go. Cette commande permet de télécharger les dépendances et d'établir 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 émettant 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, et vous avez testé ce 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 mise en correspondance.

5. Déployer le service de mise en correspondance

Présentation générale du service

Le service de mise en correspondance est une API REST écrite en Go qui exploite le framework gin.

9aecd571df0dcd7c.png

Dans cette API, les jeux sont créés et fermés. Lors de la création d'un jeu, 10 joueurs qui ne sont pas en train de jouer à un jeu sont affectés à celui-ci.

Lorsqu'un jeu est fermé, un gagnant est sélectionné au hasard et chaque joueur les statistiques 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 par la suite.

Le fichier ./src/golang/matchmaking-service/main.go du service de mise en correspondance suit une configuration et un code semblables à ceux du service profile. Il n'est donc pas répété ici. Ce service expose deux points de terminaison principaux comme suit:

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 un struct Game, ainsi que des structs Player et PlayerStats réduits:

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 un jeu, 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 affecter 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 en utilisant la capacité TABLESPACE RESERVOIR de GoogleSQL.

Il est un peu plus compliqué de fermer un jeu. Elle consiste à choisir un gagnant au hasard parmi les joueurs, à indiquer l'heure de fin du jeu et à mettre à jour les statistiques pour games_played et games_won.

En raison de cette complexité et du nombre de modifications, des mutations sont de nouveau choisies pour fermer 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 via des variables d'environnement, comme décrit dans le fichier ./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 profile-service, ce service s'exécute sur localhost:8081 par défaut.

Avec ces informations, il est maintenant temps d'exécuter le service de mise en correspondance.

Exécuter le service de mise en correspondance

Exécutez le service à l'aide de la commande go. Le service s'exécute alors sur le port 8082. Ce service possède un grand nombre des mêmes dépendances que profile-service. Les nouvelles dépendances ne seront donc 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

Tester le service pour créer un jeu Commencez par ouvrir un nouveau terminal dans Cloud Shell:

90eceac76a6bb90b.png

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"

Fermer 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 mise en correspondance pour gérer la création des jeux et leur attribuer des joueurs. Ce service gère également la fermeture d'une partie, ce qui sélectionne un gagnant au hasard et met à jour l'ensemble des statistiques pour games_played et games_won.

Étapes suivantes

Maintenant que vos services fonctionnent, il est temps d'inciter les joueurs à s'inscrire et à jouer !

6. Jouer

Maintenant que les services de profil et de mise en correspondance sont en cours d'exécution, vous pouvez générer une charge à l'aide des générateurs Locusts 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

Tout d'abord, vous devez générer des joueurs.

Le code Python permettant de créer des lecteurs 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, adresses e-mail et mots de passe des joueurs sont générés de manière aléatoire.

Les joueurs qui sont inscrits sont récupérés par une seconde tâche afin de 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ère 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

Joueurs rejoignent les jeux

Maintenant que vos joueurs 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 selon un ratio de 2:1 (open:close). Cette commande exécute 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, puis vous avez exécuté des simulations pour que les joueurs jouent à l'aide du service de mise en correspondance. Ces simulations utilisaient le framework Python Locust pour envoyer des requêtes aux ressources API REST.

N'hésitez pas à modifier le temps passé à créer des joueurs et à jouer, ainsi que le nombre d'utilisateurs simultanés (-u).

Étapes suivantes

Après la simulation, vous devrez vérifier différentes statistiques en interrogeant Spanner.

7. Récupérer les statistiques de jeu

Maintenant que nous disposons de simulations de joueurs pouvant s'inscrire et jouer à des jeux, vous devriez vérifier vos statistiques.

Pour ce faire, envoyez des requêtes de requête à Spanner à l'aide de la console Cloud.

b5e3154c6f7cb0cf.png

Comparaison des matchs ouverts et fermés

Une partie fermée est une partie dont le code temporel finished est renseigné, tandis qu'une partie ouverte aura la valeur finished NULL. Cette valeur est définie lorsque le jeu est fermé.

Cette requête va donc vous permettre de vérifier le nombre de matchs ouverts et fermés:

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:

Type

NumGames

Open Games

0

Closed Games

175

Vérifier le nombre de joueurs qui jouent et ceux qui ne jouent pas

Un joueur joue à un jeu si sa colonne current_game est définie. Dans le cas contraire, il n'est pas en train de jouer à un jeu.

Par conséquent, pour comparer le nombre de joueurs en cours de partie 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:

Type

NumPlayers

Playing

0

Not Playing

310

Déterminer les meilleurs gagnants

Lorsqu'un jeu est fermé, l'un des joueurs est sélectionné au hasard pour gagner. Les statistiques games_won de ce joueur sont incrémentées lors de la fermeture du jeu.

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

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

Résumé

Au cours de cette étape, vous avez examiné différentes statistiques concernant les joueurs et les jeux en utilisant la console Cloud pour interroger Spanner.

Étapes suivantes

Il est maintenant temps de faire le ménage !

8. Nettoyage (facultatif)

Pour effectuer un 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 de l'atelier de programmation intitulée "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 divers aspects de l'utilisation de Spanner à l'aide du pilote Golang. Cela devrait vous donner une meilleure base pour comprendre des concepts essentiels tels que:

  • Conception de schémas
  • LMD et mutations
  • Utiliser Golang

N'oubliez pas de consulter l'atelier de programmation Cloud Spanner Game Trading Post pour découvrir un autre exemple d'utilisation de Spanner en tant que backend pour votre jeu.