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.
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
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:
und klicken Sie auf „NEUES PROJEKT“, Schaltfläche zum Erstellen eines neuen Projekts:
Wenn Sie noch kein Projekt haben, sollten Sie ein Dialogfeld wie dieses sehen, um Ihr erstes zu erstellen:
Im nachfolgenden Dialog zur Projekterstellung können Sie die Details Ihres neuen Projekts eingeben:
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.
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.
- Klicken Sie einfach auf „Cloud Shell aktivieren“
, um Cloud Shell über die Cloud Console zu aktivieren. Es dauert nur einen Moment, bis die Umgebung bereitgestellt und eine Verbindung hergestellt werden kann.
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:
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ü nach dem Spanner-Eintrag
oder drücken Sie „/“, um nach Spanner zu suchen und geben Sie „Spanner“ ein
Klicken Sie als Nächstes auf 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.
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.
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:
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:
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.
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.
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.
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:
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.
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:
|
|
|
|
|
|
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:
|
|
|
|
|
|
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 | {"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 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.