Cloud Spanner – Erste Schritte bei der Spieleentwicklung

1. Einführung

Cloud Spanner ist ein vollständig verwalteter, horizontal skalierbarer, global verteilter relationaler Datenbankdienst, der ACID-Transaktionen und SQL-Semantik bietet, ohne auf Leistung und Hochverfügbarkeit zu verzichten.

Diese Features machen Spanner zu einer hervorragenden Lösung für die Architektur von Spielen, die eine globale Spieler-Community ermöglichen oder sich Gedanken über die Datenkonsistenz machen möchten.

In diesem Lab erstellen Sie zwei Go-Dienste, die mit einer regionalen Spanner-Datenbank interagieren, damit sich Spieler registrieren und mit dem Spielen beginnen können.

413fdd57bb0b68bc.png

Als Nächstes generieren Sie Daten mithilfe des Python-Lade-Frameworks Locust.io, um zu simulieren, dass sich Spieler registrieren und das Spiel spielen. Dann fragen Sie Spanner ab, um zu ermitteln, wie viele Spieler spielen, sowie Statistiken über die Gewonnene und gespielte Spiele.

Zum Schluss bereinigen Sie die in diesem Lab erstellten Ressourcen.

Aufgaben

Aufgaben in diesem Lab:

  • Spanner-Instanz erstellen
  • In Go geschriebenen Profildienst für die Spielerregistrierung bereitstellen
  • Einen in Go geschriebenen Zuordnungsdienst bereitstellen, um Spieler bestimmten Spielen zuzuweisen, Gewinner zu ermitteln und Spieler zu aktualisieren Spielstatistiken.

Lerninhalte

  • Cloud Spanner-Instanz einrichten
  • Spieledatenbank und -schema erstellen
  • Go-Anwendungen für Cloud Spanner bereitstellen
  • Locust
  • Daten in Cloud Spanner abfragen, um Fragen zu Spielen und Spielern zu beantworten.

Voraussetzungen

  • Ein Google Cloud-Projekt, das mit einem Rechnungskonto verbunden ist.
  • Ein Webbrowser wie Chrome oder Firefox

2. Einrichtung und Anforderungen

Projekt erstellen

Wenn Sie noch kein Google-Konto (Gmail oder Google Apps) haben, müssen Sie eines erstellen. Melden Sie sich in der Google Cloud Platform Console ( console.cloud.google.com) an und erstellen Sie ein neues Projekt.

Wenn Sie bereits ein Projekt haben, klicken Sie auf das Drop-down-Menü für die Projektauswahl oben links in der Konsole:

6c9406d9b014760.png

und klicken Sie auf „NEUES PROJEKT“, Schaltfläche zum Erstellen eines neuen Projekts:

949d83c8a4ee17d9.png

Wenn Sie noch kein Projekt haben, sollten Sie ein Dialogfeld wie dieses sehen, um Ihr erstes zu erstellen:

870a3cbd6541ee86.png

Im nachfolgenden Dialog zur Projekterstellung können Sie die Details Ihres neuen Projekts eingeben:

6a92c57d3250a4b3.png

Denken Sie an die Projekt-ID. Dies ist ein eindeutiger Name, der in allen Google Cloud-Projekten eindeutig ist. Der oben angegebene Name ist bereits vergeben und funktioniert leider nicht für Sie. Sie wird in diesem Codelab später als PROJECT_ID bezeichnet.

Falls noch nicht geschehen, müssen Sie als Nächstes in der Developers Console die Abrechnung aktivieren, um Google Cloud-Ressourcen nutzen und die Cloud Spanner API aktivieren zu können.

15d0ef27a8fbab27.png

Dieses Codelab sollte nicht mehr als ein paar Euro kosten. Wenn Sie sich jedoch dazu entschließen, mehr Ressourcen zu verwenden oder diese weiter auszuführen (siehe Abschnitt „Bereinigen“ am Ende dieses Dokuments), Die Preise für Google Cloud Spanner finden Sie hier.

Neue Google Cloud Platform-Nutzer haben Anspruch auf eine kostenlose Testversion mit 300$Guthaben, wodurch das Codelab in der Regel kostenlos sein sollte.

Google Cloud Shell einrichten

Sie können Google Cloud und Spanner per Fernzugriff von Ihrem Laptop aus bedienen. In diesem Codelab verwenden wir jedoch Google Cloud Shell, eine Befehlszeilenumgebung, die in der Cloud ausgeführt wird.

Diese Debian-basierte virtuelle Maschine verfügt über alle erforderlichen Entwicklungstools. Es bietet ein Basisverzeichnis mit 5 GB nichtflüchtigem Speicher und wird in Google Cloud ausgeführt. Dadurch werden die Netzwerkleistung und die Authentifizierung erheblich verbessert. Für dieses Codelab benötigen Sie also nur einen Browser – ja, er funktioniert auf Chromebooks.

  1. Klicken Sie einfach auf „Cloud Shell aktivieren“ gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A, um Cloud Shell über die Cloud Console zu aktivieren. Es dauert nur einen Moment, bis die Umgebung bereitgestellt und eine Verbindung hergestellt werden kann.

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

Screen Shot 2017-06-14 at 10.13.43 PM.png

Sobald Sie mit Cloud Shell verbunden sind, sollten Sie sehen, dass Sie bereits authentifiziert sind und dass das Projekt bereits auf Ihre PROJECT_ID eingestellt ist.

gcloud auth list

Befehlsausgabe

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

Befehlsausgabe

[core]
project = <PROJECT_ID>

Sollte das Projekt aus irgendeinem Grund nicht eingerichtet sein, geben Sie einfach den folgenden Befehl ein:

gcloud config set project <PROJECT_ID>

Sie suchen Ihre PROJECT_ID? Sehen Sie nach, welche ID Sie bei den Einrichtungsschritten verwendet haben, oder rufen Sie sie im Dashboard der Cloud Console auf:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell legt außerdem standardmäßig einige Umgebungsvariablen fest, die bei der Ausführung zukünftiger Befehle nützlich sein können.

echo $GOOGLE_CLOUD_PROJECT

Befehlsausgabe

<PROJECT_ID>

Code herunterladen

Sie können den Code für dieses Lab in Cloud Shell herunterladen. Da dies auf dem Release v0.1.0 basiert, sehen Sie sich dieses Tag an:

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

Befehlsausgabe

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

Locust-Lastgenerator einrichten

Locust ist ein Python-Framework für Lasttests, mit dem sich REST API-Endpunkte testen lassen. In diesem Codelab gibt es im Bereich „Generatoren“ zwei verschiedene Lasttests das wir hervorheben:

  • authentication_server.py: Enthält Aufgaben zum Erstellen von Spielern und zum Abrufen eines zufälligen Spielers, um Single-Point-Lookups nachzuahmen.
  • match_server.py: Enthält Aufgaben zum Erstellen und Schließen von Spielen. Beim Erstellen von Spielen werden 100 zufällig ausgewählte Spieler zugewiesen, die derzeit keine Spiele spielen. Bei geschlossenen Spielen werden die Statistiken „games_played“ und „games_won“ aktualisiert und diese Spieler einem zukünftigen Spiel zugewiesen.

Damit Locust in Cloud Shell ausgeführt werden kann, benötigen Sie Python 3.7 oder höher. Python 3.9 ist bereits in Cloud Shell enthalten. Sie müssen also nur die Version validieren:

python -V

Befehlsausgabe

Python 3.9.12

Jetzt können Sie die Anforderungen für Locust installieren.

pip3 install -r requirements.txt

Befehlsausgabe

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

Aktualisieren Sie nun den PATH, damit die neu installierte Binärdatei locust gefunden wird:

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

Befehlsausgabe

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

Zusammenfassung

In diesem Schritt haben Sie ein Projekt eingerichtet, falls noch nicht geschehen, Cloud Shell aktiviert und den Code für dieses Lab heruntergeladen.

Schließlich richten Sie Locust für die Lastgenerierung später im Lab ein.

Nächstes Video

Als Nächstes richten Sie die Cloud Spanner-Instanz und -Datenbank ein.

3. Spanner-Instanz und -Datenbank erstellen

Spanner-Instanz erstellen

In diesem Schritt richten wir unsere Spanner-Instanz für das Codelab ein. Suchen Sie im linken Hamburger-Menü 3129589f7bc9e5ce.png nach dem Spanner-Eintrag 1a6580bd3d3e6783.png oder drücken Sie „/“, um nach Spanner zu suchen und geben Sie „Spanner“ ein

36e52f8df8e13b99.png

Klicken Sie als Nächstes auf 95269e75bc8c3e4d.png und füllen Sie das Formular aus. Geben Sie dazu den Instanznamen cloudspanner-gaming für Ihre Instanz ein, wählen Sie eine Konfiguration aus (wählen Sie eine regionale Instanz wie us-central1 aus) und legen Sie die Anzahl der Knoten fest. Für dieses Codelab benötigen wir nur 500 processing units.

Klicken Sie abschließend auf „Erstellen“. und innerhalb von Sekunden steht eine Cloud Spanner-Instanz zur Verfügung.

4457c324c94f93e6.png

Datenbank und Schema erstellen

Sobald die Instanz ausgeführt wird, können Sie die Datenbank erstellen. Spanner ermöglicht die Verwendung mehrerer Datenbanken in einer einzelnen Instanz.

In der Datenbank definieren Sie Ihr Schema. Sie können auch steuern, wer Zugriff auf die Datenbank hat, eine benutzerdefinierte Verschlüsselung einrichten, das Optimierungstool konfigurieren und die Aufbewahrungsdauer festlegen.

Bei multiregionalen Instanzen können Sie auch den standardmäßigen Leader konfigurieren. Weitere Informationen zu Datenbanken in Spanner.

Für dieses Codelab erstellen Sie die Datenbank mit Standardoptionen und geben das Schema bei der Erstellung an.

In diesem Lab werden zwei Tabellen erstellt: players und games.

77651ac12e47fe2a.png

Spieler können im Laufe der Zeit an vielen Spielen teilnehmen, jedoch nur an einem Spiel gleichzeitig. Spieler haben auch stats als JSON-Datentyp, um interessante Statistiken wie games_played und games_won zu verfolgen. Da später weitere Statistiken hinzugefügt werden können, ist dies praktisch eine schemalose Spalte für Spieler.

Spiele erfassen die teilnehmenden Spieler mithilfe des ARRAY-Datentyps von Spanner. Die Attribute für Sieger und beendete Spiele werden erst ausgefüllt, wenn das Spiel beendet ist.

Es gibt einen Fremdschlüssel, um sicherzustellen, dass current_game des Spielers ein gültiges Spiel ist.

Erstellen Sie nun die Datenbank, indem Sie auf „Datenbank erstellen“ klicken. in der Instanzübersicht:

a820db6c4a4d6f2d.png

Tragen Sie dann die Details ein. Die wichtigen Optionen sind der Datenbankname und der Dialekt. In diesem Beispiel haben wir die Datenbank sample-game genannt und den Standard-SQL-Dialekt von Google ausgewählt.

Kopieren Sie als Schema diese DDL und fügen Sie sie in das Feld ein:

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

Klicken Sie dann auf die Schaltfläche „Create“ (Erstellen) und warten Sie ein paar Sekunden, bis die Datenbank erstellt ist.

Die Seite zum Erstellen einer Datenbank sollte wie folgt aussehen:

d39d358dc7d32939.png

Jetzt müssen Sie in Cloud Shell einige Umgebungsvariablen festlegen, die später im Code-Lab verwendet werden. Notieren Sie sich die Instanz-ID und legen Sie die INSTANCE_ID und die DATABASE_ID in Cloud Shell fest.

f6f98848d3aea9c.png

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

Zusammenfassung

In diesem Schritt haben Sie eine Spanner-Instanz und die Datenbank sample-game erstellt. Außerdem haben Sie das Schema definiert, das in diesem Beispielspiel verwendet wird.

Nächstes Video

Als Nächstes stellen Sie den Profildienst bereit, damit sich die Spieler für das Spiel registrieren können.

4. Profildienst bereitstellen

Überblick über den Service

Der Profildienst ist eine in Go geschriebene REST API, die das Gin-Framework nutzt.

4fce45ee6c858b3e.png

In dieser API können sich Spieler zum Spielen registrieren. Dies wird durch einen einfachen POST-Befehl erstellt, bei dem ein Spielername, eine E-Mail-Adresse und ein Passwort akzeptiert werden. Das Passwort wird mit bcrypt verschlüsselt und der Hash in der Datenbank gespeichert.

Die E-Mail-Adresse wird als eindeutige Kennung behandelt, während der player_name zu Anzeigezwecken für das Spiel verwendet wird.

Diese API übernimmt die Anmeldung derzeit nicht. Die Implementierung dieser Funktion kann Ihnen jedoch überlassen werden.

Die Datei ./src/golang/profile-service/main.go für den Profildienst macht zwei primäre Endpunkte so verfügbar:

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

Der Code für diese Endpunkte wird an das player-Modell weitergeleitet.

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

Als Erstes legt der Dienst die Spanner-Verbindung fest. Dies wird auf Dienstebene implementiert, um den Sitzungspool für den Dienst zu erstellen.

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 und PlayerStats sind wie folgt definiert:

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

Die Funktion zum Hinzufügen des Players verwendet eine DML-Insert-Anweisung innerhalb einer ReadWrite-Transaktion, da das Hinzufügen von Playern eine einzelne Anweisung und keine Batch-Insert-Anweisungen ist. Die Funktion sieht so aus:

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
}

Um einen Spieler anhand seiner UUID abzurufen, wird ein einfacher Lesevorgang ausgegeben. Dadurch werden playerUUID, player_name, email und stats für den Player abgerufen.

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
}

Standardmäßig wird der Dienst mithilfe von Umgebungsvariablen konfiguriert. Weitere Informationen finden Sie im entsprechenden Abschnitt der Datei ./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
}

Wie Sie sehen, wird der Dienst standardmäßig auf localhost:8080 ausgeführt.

Anhand dieser Informationen kann der Dienst ausgeführt werden.

Profildienst ausführen

Führen Sie den Dienst mit dem Befehl „go“ aus. Dadurch werden Abhängigkeiten heruntergeladen und der Dienst wird eingerichtet, der auf Port 8080 ausgeführt wird:

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

Befehlsausgabe:

[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

Testen Sie den Dienst mit einem curl-Befehl:

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

Befehlsausgabe:

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"

Zusammenfassung

In diesem Schritt haben Sie den Profildienst bereitgestellt, mit dem sich Spieler für Ihr Spiel anmelden können, und Sie haben den Dienst getestet, indem Sie einen POST API-Aufruf ausgegeben haben, um einen neuen Spieler zu erstellen.

Nächste Schritte

Im nächsten Schritt stellen Sie den Zuordnungsdienst bereit.

5. Zuordnungsdienst bereitstellen

Überblick über den Service

Der Zuordnungsdienst ist eine in Go geschriebene REST API, die das Gin-Framework nutzt.

9aecd571df0dcd7c.png

In dieser API werden Spiele erstellt und geschlossen. Wenn ein Spiel erstellt wird, werden ihm zehn Spieler zugewiesen, die es derzeit nicht spielen.

Wenn ein Spiel geschlossen wird, wird ein Gewinner nach dem Zufallsprinzip ausgewählt und Die Statistiken für games_played und games_won wurden angepasst. Außerdem wird jeder Spieler aktualisiert, um anzugeben, dass er nicht mehr spielt, sodass er für zukünftige Spiele verfügbar ist.

Die Datei ./src/golang/matchmaking-service/main.go für den Zuordnungsdienst folgt einer ähnlichen Einrichtung und einem ähnlichen Code wie der profile-Dienst. Daher wird sie hier nicht wiederholt. Dieser Dienst stellt zwei primäre Endpunkte so bereit:

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

Dieser Dienst bietet eine Game-Struktur sowie reduzierte Player- und PlayerStats-Strukturen:

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

Um ein Spiel zu erstellen, holt sich die Partnervermittlung eine zufällige Auswahl von 100 Spielern, die derzeit kein Spiel spielen.

Für die Erstellung des Spiels und die Zuweisung der Spieler werden Spanner-Mutationen ausgewählt, da Mutationen bei großen Änderungen leistungsfähiger sind als DML.

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

Die zufällige Auswahl von Spielern erfolgt mit SQL unter Verwendung der GoogleSQL-Funktion TABLESPACE RESERVOIR.

Es ist etwas komplizierter, ein Spiel zu schließen. Dabei wird unter den Spielern ein zufälliger Gewinner ausgewählt, der Zeitpunkt markiert, zu dem das Spiel beendet ist, und die Statistiken für games_played und games_won.

Aufgrund dieser Komplexität und der Menge an Änderungen werden erneut Mutationen ausgewählt, um das Spiel abzuschließen.

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
}

Die Konfiguration wird wiederum über Umgebungsvariablen gehandhabt, wie unter ./src/golang/matchmaking-service/config/config.go für den Dienst beschrieben.

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

Dieser Dienst wird standardmäßig auf localhost:8081 ausgeführt, um Konflikte mit dem Profildienst zu vermeiden.

Mit diesen Informationen ist es nun an der Zeit, den Zuordnungsdienst auszuführen.

Zuordnungsdienst ausführen

Führen Sie den Dienst mit dem Befehl „go“ aus. Dadurch wird der Dienst eingerichtet, der auf Port 8082 ausgeführt wird. Dieser Dienst hat viele der gleichen Abhängigkeiten wie der Profildienst, sodass keine neuen Abhängigkeiten heruntergeladen werden.

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

Befehlsausgabe:

[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

Spiel erstellen

Teste den Dienst, um ein Spiel zu erstellen. Öffnen Sie zuerst ein neues Terminal in Cloud Shell:

90eceac76a6bb90b.png

Führen Sie dann den folgenden curl-Befehl aus:

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

Befehlsausgabe:

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"

Spiel schließen

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

Befehlsausgabe:

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"

Zusammenfassung

In diesem Schritt haben Sie den Zuordnungsdienst bereitgestellt, um Spiele zu erstellen und diesem Spiel Spieler zuzuweisen. Dieser Dienst übernimmt auch den Abschluss eines Spiels, bei dem ein zufälliger Gewinner ermittelt und die Statistiken für games_played und games_won.

Nächste Schritte

Da Ihre Dienste nun ausgeführt werden, ist es an der Zeit, die Spieler dazu zu bringen, sich anzumelden und Spiele zu spielen!

6. Jetzt spielen

Nachdem die Profil- und Partnerzuordnungsdienste nun ausgeführt werden, können Sie mithilfe der bereitgestellten Locust-Generatoren Last generieren.

Locust bietet eine Weboberfläche zum Ausführen der Generatoren. In diesem Lab verwenden Sie jedoch die Befehlszeile (Option –headless).

Spieler registrieren

Zuerst möchten Sie Spieler generieren.

Der Python-Code zum Erstellen von Playern in der Datei ./generators/authentication_server.py sieht so aus:

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

Spielernamen, E-Mail-Adressen und Passwörter werden nach dem Zufallsprinzip generiert.

Spieler, die erfolgreich registriert wurden, werden durch eine zweite Aufgabe abgerufen, um Leselasten zu generieren.

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

Der folgende Befehl ruft die Datei ./generators/authentication_server.py auf, die 30 Sekunden lang (t=30s) mit einer Gleichzeitigkeit von zwei Threads (u=2) neue Spieler generiert:

cd ~/spanner-gaming-sample
locust -H http://127.0.0.1:8080 -f ./generators/authentication_server.py --headless -u=2 -r=2 -t=30s

Spieler nehmen an Spielen teil

Nachdem die Spieler sich registriert haben, möchten sie jetzt mit dem Spielen beginnen.

Der Python-Code zum Erstellen und Schließen von Spielen in der Datei ./generators/match_server.py sieht so aus:

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)

Wenn dieser Generator ausgeführt wird, werden Spiele mit einem Verhältnis von 2:1 (open:close) geöffnet und geschlossen. Mit diesem Befehl wird der Generator 10 Sekunden lang ausgeführt (-t=10s):

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

Zusammenfassung

In diesem Schritt haben Sie simuliert, wie sich Spieler für Spiele anmelden, und dann Simulationen für Spiele mit dem Partnervermittlungsdienst ausgeführt. Bei diesen Simulationen wurde das Locust-Python-Framework genutzt, um Anfragen an die Dienste REST API.

Sie können die für das Erstellen von Spielern und Spielen aufgewendete Zeit sowie die Anzahl der gleichzeitigen Nutzer (-u) ändern.

Nächste Schritte

Nach der Simulation sollten Sie verschiedene Statistiken überprüfen, indem Sie Spanner abfragen.

7. Spielstatistiken abrufen

Da nun simulierte Spieler sich anmelden und Spiele spielen können, sollten Sie Ihre Statistiken überprüfen.

Verwenden Sie dazu die Cloud Console, um Abfrageanfragen an Spanner zu senden.

b5e3154c6f7cb0cf.png

Offene vs. geschlossene Spiele

Bei einem geschlossenen Spiel ist der Zeitstempel beendet angegeben, während bei einem offenen Spiel der Wert NULL bei beendet liegt. Dieser Wert wird festgelegt, wenn das Spiel geschlossen wird.

Mit dieser Abfrage können Sie also herausfinden, wie viele Spiele offen und wie viele geschlossen sind:

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
)

Ergebnis:

Type

NumGames

Open Games

0

Closed Games

175

Prüfen der Anzahl der Spieler, die spielen oder nicht spielen

Ein Spieler spielt ein Spiel, wenn seine Spalte current_game angegeben ist. Andernfalls spielen sie gerade kein Spiel.

Um zu vergleichen, wie viele Spieler derzeit spielen und nicht spielen, verwenden Sie diese Abfrage:

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
)

Ergebnis:

Type

NumPlayers

Playing

0

Not Playing

310

Top-Gewinner bestimmen

Wenn ein Spiel geschlossen wird, wird einer der Spieler nach dem Zufallsprinzip als Gewinner ausgewählt. Die games_won-Statistik dieses Spielers wird beim Ende des Spiels erhöht.

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

Ergebnis:

playerUUID

Statistiken

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}

Zusammenfassung

In diesem Schritt haben Sie mithilfe der Cloud Console verschiedene Statistiken zu Spielern und Spielen überprüft, um Spanner abzufragen.

Nächste Schritte

Als Nächstes ist es an der Zeit, aufzuräumen!

8. Bereinigen (optional)

Wechseln Sie zum Bereinigen einfach zum Cloud Spanner-Bereich der Cloud Console und löschen Sie die Instanz cloudspanner-gaming, die Sie im Codelab-Schritt „Cloud Spanner-Instanz einrichten“ erstellt haben.

9. Glückwunsch!

Herzlichen Glückwunsch, Sie haben erfolgreich ein Beispielspiel in Spanner bereitgestellt

Nächste Schritte

In diesem Lab wurden Ihnen verschiedene Themen der Arbeit mit Spanner mithilfe des golang-Treibers vorgestellt. Sie sollte Ihnen eine bessere Grundlage geben, um wichtige Konzepte wie die folgenden zu verstehen:

  • Schemadesign
  • DML im Vergleich zu Mutationen
  • Mit Golang arbeiten

Im Codelab zum Game Trading Post finden Sie ein weiteres Beispiel für die Arbeit mit Spanner als Back-End für Ihr Spiel.