1. Einführung
Cloud Spanner ist ein vollständig verwalteter, global verteilter, horizontal skalierbarer, relationaler Datenbankdienst, der ACID-Transaktionen und SQL-Semantik bietet, ohne Leistung und Hochverfügbarkeit zu beeinträchtigen.
Diese Funktionen machen Spanner zu einer hervorragenden Lösung für die Architektur von Spielen, die eine globale Spielerbasis ermöglichen möchten oder bei denen Datenkonsistenz wichtig ist.
In diesem Lab erstellen Sie zwei Go-Dienste, die mit einer regionalen Spanner-Datenbank interagieren, damit sich die Spieler registrieren und spielen können.

Als Nächstes generieren Sie Daten mit dem Python-Framework Locust.io, um zu simulieren, dass sich Spieler registrieren und das Spiel spielen. Anschließend fragen Sie Spanner ab, um die Anzahl der Spieler und einige Statistiken zu den gewonnenen Spielen im Vergleich zu den gespielten Spielen zu ermitteln.
Zum Schluss bereinigen Sie die Ressourcen, die in diesem Lab erstellt wurden.
Aufgaben
In diesem Lab haben Sie folgende Aufgaben:
- Spanner-Instanz erstellen
- Profil-Dienst in Go bereitstellen, um die Registrierung von Spielern zu verarbeiten
- Stellen Sie einen in Go geschriebenen Matchmaking-Dienst bereit, um Spieler Spielen zuzuweisen, Gewinner zu ermitteln und die Spielstatistiken der Spieler zu aktualisieren.
Lerninhalte
- Cloud Spanner-Instanz einrichten
- So erstellen Sie eine Spieledatenbank und ein Schema
- Go-Anwendungen für die Verwendung mit Cloud Spanner bereitstellen
- Daten mit Locust generieren
- Daten in Cloud Spanner abfragen, um Fragen zu Spielen und Spielern zu beantworten
Voraussetzungen
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 Console ( console.cloud.google.com) an und erstellen Sie ein neues Projekt.
Wenn Sie bereits ein Projekt haben, klicken Sie oben links in der Console auf das Drop-down-Menü zur Projektauswahl:

Klicken Sie im angezeigten Dialogfeld auf die Schaltfläche „NEUES PROJEKT“, um ein neues Projekt zu erstellen:

Wenn Sie noch kein Projekt haben, wird ein Dialogfeld wie das folgende angezeigt, in dem Sie Ihr erstes Projekt erstellen können:

Im nachfolgenden Dialogfeld zum Erstellen von Projekten können Sie die Details Ihres neuen Projekts eingeben:

Merken Sie sich die Projekt-ID. Sie ist für alle Google Cloud-Projekte ein eindeutiger Name. Der Name oben ist bereits vergeben und kann nicht verwendet werden. Sie wird später in diesem Codelab als PROJECT_ID bezeichnet.
Als Nächstes müssen Sie, falls noch nicht geschehen, die Abrechnung in der Entwicklerkonsole aktivieren, um Google Cloud-Ressourcen verwenden zu können, und die Cloud Spanner API aktivieren.

Dieses Codelab sollte Sie nicht mehr als ein paar Dollar kosten, aber es könnte mehr sein, wenn Sie sich für mehr Ressourcen entscheiden oder wenn Sie sie laufen lassen (siehe Abschnitt „Bereinigen“ am Ende dieses Dokuments). Die Preise für Google Cloud Spanner sind hier dokumentiert.
Neuen Nutzern der Google Cloud Platform steht eine kostenlose Testversion mit einem Guthaben von 300$ zur Verfügung. Dieses Codelab sollte damit vollständig kostenlos sein.
Google Cloud Shell-Einrichtung
Während Sie Google Cloud und Spanner von Ihrem Laptop aus per Fernzugriff nutzen können, wird in diesem Codelab Google Cloud Shell verwendet, eine Befehlszeilenumgebung, die in der Cloud ausgeführt wird.
Diese Debian-basierte virtuelle Maschine verfügt über alle Entwicklungstools, die Sie benötigen. Sie bietet ein Basisverzeichnis mit 5 GB nichtflüchtigem Speicher und läuft in Google Cloud, was die Netzwerkleistung und Authentifizierung erheblich verbessert. Für dieses Codelab benötigen Sie also nur einen Browser (es funktioniert auch auf einem Chromebook).
- Klicken Sie zum Aktivieren von Cloud Shell in der Cloud Console einfach auf „Cloud Shell aktivieren“
. Die Bereitstellung und Verbindung mit der Umgebung sollte nur wenige Augenblicke dauern.


Sobald die Verbindung mit der Cloud Shell hergestellt ist, sehen Sie, dass Sie bereits authentifiziert sind und für das Projekt schon Ihre PROJECT_ID eingestellt ist.
gcloud auth list
Befehlsausgabe
Credentialed accounts:
- <myaccount>@<mydomain>.com (active)
gcloud config list project
Befehlsausgabe
[core]
project = <PROJECT_ID>
Wenn das Projekt aus irgendeinem Grund nicht festgelegt ist, führen Sie einfach den folgenden Befehl aus:
gcloud config set project <PROJECT_ID>
Sie wissen nicht, wie Ihre PROJECT_ID lautet? Sehen Sie nach, welche ID Sie in den Einrichtungsschritten verwendet haben, oder suchen Sie sie im Cloud Console-Dashboard:

In Cloud Shell werden auch einige Umgebungsvariablen standardmäßig festgelegt, die für zukünftige Befehle nützlich sein können.
echo $GOOGLE_CLOUD_PROJECT
Befehlsausgabe
<PROJECT_ID>
Code herunterladen
In Cloud Shell können Sie den Code für dieses Lab herunterladen. Dies basiert auf der Version v0.1.0. Prüfen Sie also dieses 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
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, das sich zum Testen von REST API-Endpunkten eignet. In diesem Codelab gibt es zwei verschiedene Lasttests im Verzeichnis „generators“, die wir uns genauer ansehen werden:
- authentication_server.py: Enthält Aufgaben zum Erstellen von Spielern und zum Abrufen eines zufälligen Spielers, um Einzelpunkt-Lookups zu simulieren.
- match_server.py: Enthält Aufgaben zum Erstellen und Schließen von Spielen. Beim Erstellen von Spielen werden 100 zufällige Spieler zugewiesen, die gerade nicht spielen. Wenn Sie Spiele schließen, werden die Statistiken „Gespielte Spiele“ und „Gewonnene Spiele“ aktualisiert und die Spieler können einem zukünftigen Spiel zugewiesen werden.
Damit Locust in Cloud Shell ausgeführt werden kann, benötigen Sie Python 3.7 oder höher. Cloud Shell wird mit Python 3.9 ausgeliefert. Sie müssen also nur die Version prüfen:
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 werden kann:
PATH=~/.local/bin":$PATH"
which locust
Befehlsausgabe
/home/<user>/.local/bin/locust
Zusammenfassung
In diesem Schritt haben Sie Ihr Projekt eingerichtet, falls Sie noch keines hatten, Cloud Shell aktiviert und den Code für dieses Lab heruntergeladen.
Schließlich richten Sie Locust für die Lastgenerierung ein, die später in diesem Lab erfolgt.
Nächster Schritt
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 Cloud Spanner-Instanz für das Codelab ein. Suchen Sie nach dem Spanner-Eintrag
im Dreistrich-Menü oben links
oder suchen Sie nach Spanner, indem Sie „/“ drücken und „Spanner“ eingeben.

Klicken Sie dann auf
und füllen Sie das Formular aus, indem Sie den Instanznamen cloudspanner-gaming für Ihre Instanz eingeben, eine Konfiguration auswählen (z. B. eine regionale Instanz wie us-central1) und die Anzahl der Knoten festlegen. Für dieses Codelab benötigen wir nur 500 processing units.
Klicken Sie abschließend auf „Erstellen“. Innerhalb von Sekunden steht Ihnen eine Cloud Spanner-Instanz zur Verfügung.

Datenbank und Schema erstellen
Sobald Ihre Instanz ausgeführt wird, können Sie die Datenbank erstellen. Spanner ermöglicht mehrere Datenbanken in einer einzelnen Instanz.
In der Datenbank definieren Sie Ihr Schema. Sie können auch festlegen, wer Zugriff auf die Datenbank hat, eine benutzerdefinierte Verschlüsselung einrichten, den Optimierer konfigurieren und den Aufbewahrungszeitraum festlegen.
Bei multiregionalen Instanzen können Sie auch den Standard-Leader konfigurieren. Weitere Informationen zu Datenbanken in Spanner
In diesem 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.

Spieler können im Laufe der Zeit an vielen Spielen teilnehmen, aber jeweils nur an einem. Spieler haben auch Statistiken als JSON-Datentyp, um interessante Statistiken wie games_played und games_won im Blick zu behalten. Da später möglicherweise weitere Statistiken hinzugefügt werden, ist dies effektiv eine schemalose Spalte für Spieler.
Spiele verfolgen die teilnehmenden Spieler mithilfe des ARRAY-Datentyps von Spanner. Die Attribute „Gewinner“ und „Abgeschlossen“ eines Spiels werden erst ausgefüllt, wenn das Spiel beendet ist.
Es gibt einen Fremdschlüssel, um sicherzustellen, dass das current_game des Spielers ein gültiges Spiel ist.
Erstellen Sie nun die Datenbank, indem Sie in der Instanzübersicht auf „Datenbank erstellen“ klicken:

Geben 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 Google Standard-SQL-Dialekt ausgewählt.
Kopieren Sie für das 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 „Erstellen“ und warten Sie einige Sekunden, bis die Datenbank erstellt wurde.
Die Seite zum Erstellen von Datenbanken sollte so aussehen:

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

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. Sie haben auch das Schema definiert, das in diesem Beispielspiel verwendet wird.
Nächster Schritt
Als Nächstes stellen Sie den Profildienst bereit, damit sich Spieler für das Spiel registrieren können.
4. Profildienst bereitstellen
Überblick über den Service
Der Profildienst ist eine REST API, die in Go geschrieben wurde und das Gin-Framework nutzt.

Mit dieser API können sich Spieler für Spiele registrieren. Das Konto wird mit einem einfachen POST-Befehl erstellt, der einen Spielernamen, eine E-Mail-Adresse und ein Passwort akzeptiert. Das Passwort wird mit bcrypt verschlüsselt und der Hash wird in der Datenbank gespeichert.
Die E-Mail-Adresse wird als eindeutige Kennung behandelt, während der Spielername zu Anzeigezwecken für das Spiel verwendet wird.
Diese API verarbeitet derzeit keine Anmeldungen. Die Implementierung kann jedoch als zusätzliche Übung Ihnen überlassen werden.
Die Datei ./src/golang/profile-service/main.go für den Profilservice stellt zwei primäre Endpunkte bereit:
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 Modell player 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 wird die Spanner-Verbindung eingerichtet. 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 so definierte Strukturen:
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 Spielers nutzt einen DML-Einfügevorgang in einer ReadWrite-Transaktion, da das Hinzufügen von Spielern eine einzelne Anweisung und keine Batch-Einfügevorgänge 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 ausgeführt. Dadurch werden die playerUUID, player_name, email und stats des Spielers 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 über 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
}
Sie sehen, dass der Dienst standardmäßig auf localhost:8080 ausgeführt wird.
Mit diesen Informationen ist es an der Zeit, den Dienst auszuführen.
Profilservice ausführen
Führen Sie den Dienst mit dem Go-Befehl aus. Dadurch werden Abhängigkeiten heruntergeladen und der Dienst wird auf Port 8080 eingerichtet:
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 registrieren können. Außerdem haben Sie den Dienst getestet, indem Sie einen POST-API-Aufruf zum Erstellen eines neuen Spielers gesendet haben.
Nächste Schritte
Im nächsten Schritt stellen Sie den Matchmaking-Dienst bereit.
5. Partnervermittlungsdienst bereitstellen
Überblick über den Service
Der Matchmaking-Dienst ist eine REST API, die in Go geschrieben wurde und das Gin-Framework nutzt.

In dieser API werden Spiele erstellt und geschlossen. Wenn ein Spiel erstellt wird, werden ihm 10 Spieler zugewiesen, die gerade kein Spiel spielen.
Wenn ein Spiel geschlossen wird, wird zufällig ein Gewinner ausgewählt und die Statistiken der einzelnen Spieler für games_played (gespielte Spiele) und games_won (gewonnene Spiele) werden angepasst. Außerdem wird jeder Spieler aktualisiert, um anzugeben, dass er nicht mehr spielt und daher für zukünftige Spiele verfügbar ist.
Die Datei ./src/golang/matchmaking-service/main.go für den Matchmaking-Dienst 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 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 die schlanken Strukturen Player und PlayerStats:
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"`
}
Für ein Spiel wählt der Zuordnungsdienst zufällig 100 Spieler aus, die derzeit nicht an einem Spiel teilnehmen.
Spanner-Mutationen werden verwendet, um das Spiel zu erstellen und die Spieler zuzuweisen, da Mutationen bei großen Änderungen leistungsfähiger als DML sind.
// 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 der Spieler erfolgt mit SQL über die TABLESPACE RESERVOIR-Funktion von GoogleSQL.
Das Schließen eines Spiels ist etwas komplizierter. Dazu gehört, einen zufälligen Gewinner unter den Spielern auszuwählen, die Uhrzeit zu markieren, zu der das Spiel beendet ist, und die Statistiken der einzelnen Spieler für games_played (gespielte Spiele) und games_won (gewonnene Spiele) zu aktualisieren.
Aufgrund dieser Komplexität und der Menge an Änderungen werden Mutationen wieder ausgewählt, um das Spiel zu beenden.
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 erfolgt wieder über Umgebungsvariablen, wie in ./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")
Um Konflikte mit dem Profilservice zu vermeiden, wird dieser Dienst standardmäßig auf localhost:8081 ausgeführt.
Jetzt ist es an der Zeit, den Matchmaking-Dienst auszuführen.
Partnervermittlungsdienst ausführen
Führen Sie den Dienst mit dem Go-Befehl aus. Dadurch wird der Dienst auf Port 8082 eingerichtet. Dieser Dienst hat viele der gleichen Abhängigkeiten wie der Profil-Dienst, 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
Testen Sie den Dienst, um ein Spiel zu erstellen. Öffnen Sie zuerst ein neues Terminal in Cloud Shell:

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 Matchmaking-Dienst bereitgestellt, um Spiele zu erstellen und Spielern diese Spiele zuzuweisen. Dieser Dienst kümmert sich auch um das Beenden eines Spiels, bei dem ein zufälliger Gewinner ausgewählt und die Statistiken aller Spieler für games_played und games_won aktualisiert werden.
Nächste Schritte
Nachdem Ihre Dienste jetzt ausgeführt werden, ist es an der Zeit, dass sich Spieler registrieren und Spiele spielen.
6. Jetzt spielen
Nachdem das Profil und die Matchmaking-Dienste ausgeführt werden, können Sie mit den 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üssen Sie Spieler generieren.
Der Python-Code zum Erstellen von Spielern 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 zufällig generiert.
Spieler, die sich erfolgreich registriert haben, werden von einer zweiten Aufgabe abgerufen, um die Leselast 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]")
Mit dem folgenden Befehl wird die Datei ./generators/authentication_server.py aufgerufen, die 30 Sekunden lang (t=30s) neue Spieler mit einer Parallelität von zwei Threads gleichzeitig (u=2) 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 treten Spielen bei
Nachdem sich Spieler registriert haben, möchten sie natürlich auch spielen.
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 im Verhältnis 2:1 (öffnen:schließen) 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, dass sich Spieler für das Spielen von Spielen registrieren, und dann Simulationen für Spieler ausgeführt, die Spiele über den Matchmaking-Dienst spielen. Für diese Simulationen wurde das Locust-Python-Framework verwendet, um Anfragen an die REST API unserer Dienste zu senden.
Sie können die Zeit für das Erstellen von Spielern und das Spielen von Spielen sowie die Anzahl der gleichzeitigen Nutzer (-u) ändern.
Nächste Schritte
Nach der Simulation sollten Sie verschiedene Statistiken abrufen, indem Sie Spanner abfragen.
7. Spielstatistiken abrufen
Nachdem wir simuliert haben, dass sich Spieler registrieren und Spiele spielen können, sollten Sie Ihre Statistiken überprüfen.
Verwenden Sie dazu die Cloud Console, um Abfrageanfragen an Spanner zu senden.

Offene und geschlossene Spiele prüfen
Bei einem abgeschlossenen Spiel ist der Zeitstempel finished ausgefüllt, bei einem laufenden Spiel ist finished NULL. Dieser Wert wird festgelegt, wenn das Spiel geschlossen wird.
Mit dieser Abfrage können Sie prüfen, 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:
|
|
|
|
|
|
Anzahl der Spieler, die spielen bzw. nicht spielen, prüfen
Ein Spieler spielt ein Spiel, wenn die Spalte current_game festgelegt ist. Andernfalls spielen sie gerade kein Spiel.
Mit dieser Abfrage können Sie vergleichen, wie viele Spieler aktuell spielen und wie viele nicht:
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:
|
|
|
|
|
|
Gewinner ermitteln
Wenn ein Spiel geschlossen wird, wird einer der Spieler zufällig als Gewinner ausgewählt. Die Statistik games_won dieses Spielers wird beim Beenden 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 | {"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} |
Zusammenfassung
In diesem Schritt haben Sie verschiedene Statistiken zu Spielern und Spielen abgerufen, indem Sie Spanner über die Cloud Console abgefragt haben.
Nächste Schritte
Als Nächstes ist es an der Zeit, aufzuräumen.
8. Bereinigen (optional)
Rufen Sie zum Bereinigen einfach den Cloud Spanner-Bereich der Cloud Console auf und löschen Sie die Instanz cloudspanner-gaming, die wir im Codelab-Schritt „Cloud Spanner-Instanz einrichten“ erstellt haben.
9. Glückwunsch!
Herzlichen Glückwunsch! Sie haben ein Beispielspiel erfolgreich in Spanner bereitgestellt.
Nächste Schritte
In diesem Lab haben Sie verschiedene Themen kennengelernt, die sich mit der Arbeit mit Spanner mithilfe des Go-Treibers befassen. Sie sollten Ihnen eine bessere Grundlage für das Verständnis wichtiger Konzepte wie der folgenden bieten:
- Schemadesign
- DML im Vergleich zu Mutationen
- Mit Golang arbeiten
Sehen Sie sich das Cloud Spanner Game Trading Post-Codelab an, um ein weiteres Beispiel für die Verwendung von Spanner als Backend für Ihr Spiel zu erhalten.