Memulai Pengembangan Game Cloud Spanner

1. Pengantar

Cloud Spanner adalah layanan database relasional yang skalabel secara global dan terkelola sepenuhnya, yang menyediakan transaksi ACID dan semantik SQL tanpa mengorbankan performa dan ketersediaan tinggi.

Fitur-fitur ini membuat Spanner sangat cocok dengan arsitektur game yang ingin mendukung basis pemain global atau mengkhawatirkan konsistensi data

Di lab ini, Anda akan membuat dua layanan Go yang berinteraksi dengan database Spanner regional agar pemain dapat mendaftar dan mulai bermain.

413fdd57bb0b68bc.pngS

Selanjutnya, Anda akan menghasilkan data yang memanfaatkan framework pemuatan Python Locust.io untuk menyimulasikan pemain yang mendaftar dan memainkan game. Kemudian Anda akan mengkueri Spanner untuk menentukan berapa banyak pemain yang bermain, dan beberapa statistik tentang menang vs. pertandingan yang dimainkan.

Terakhir, Anda akan membersihkan resource yang dibuat di lab ini.

Yang akan Anda build

Sebagai bagian dari lab ini, Anda akan:

  • Membuat instance Spanner
  • Deploy layanan Profil yang ditulis dalam Go untuk menangani pendaftaran pemain
  • Gunakan layanan Pencocokan yang ditulis dalam Go untuk menugaskan pemain ke game, menentukan pemenang, dan memperbarui informasi pemain statistik game.

Yang akan Anda pelajari

  • Cara menyiapkan instance Cloud Spanner
  • Cara membuat database dan skema game
  • Cara men-deploy aplikasi Go agar berfungsi dengan Cloud Spanner
  • Cara membuat data menggunakan Locust
  • Cara membuat kueri data di Cloud Spanner untuk menjawab pertanyaan tentang game dan pemain.

Yang Anda butuhkan

  • Project Google Cloud yang terhubung ke akun penagihan.
  • Browser web, seperti Chrome atau Firefox.

2. Penyiapan dan persyaratan

Membuat project

Jika belum memiliki Akun Google (Gmail atau Google Apps), Anda harus membuatnya. Login ke konsol Google Cloud Platform ( console.cloud.google.com) dan buat project baru.

Jika Anda sudah memiliki project, klik menu pull-down pilihan project di kiri atas konsol:

6c9406d9b014760.png

dan klik tombol 'PROJECT BARU' dalam dialog yang dihasilkan untuk membuat project baru:

949d83c8a4ee17d9.pngS

Jika belum memiliki project, Anda akan melihat dialog seperti ini untuk membuat project pertama:

870a3cbd6541ee86.png

Dialog pembuatan project berikutnya memungkinkan Anda memasukkan detail project baru:

6a92c57d3250a4b3.png

Ingat project ID yang merupakan nama unik di semua project Google Cloud (maaf, nama di atas telah digunakan dan tidak akan berfungsi untuk Anda!) Project_ID selanjutnya akan dirujuk di codelab ini sebagai PROJECT_ID.

Selanjutnya, jika Anda belum melakukannya, Anda harus mengaktifkan penagihan di Developers Console untuk menggunakan resource Google Cloud dan mengaktifkan Cloud Spanner API.

15d0ef27a8fbab27.png

Menjalankan melalui codelab ini tidak akan menghabiskan biaya lebih dari beberapa dolar, tetapi bisa lebih jika Anda memutuskan untuk menggunakan lebih banyak resource atau jika Anda membiarkannya berjalan (lihat bagian "pembersihan" di akhir dokumen ini). Harga Google Cloud Spanner didokumentasikan di sini.

Pengguna baru Google Cloud Platform memenuhi syarat untuk mendapatkan uji coba gratis senilai $300, yang menjadikan codelab ini sepenuhnya gratis.

Penyiapan Google Cloud Shell

Meskipun Google Cloud dan Spanner dapat dioperasikan dari jarak jauh menggunakan laptop Anda, dalam codelab ini, kita akan menggunakan Google Cloud Shell, lingkungan command line yang berjalan di Cloud.

Mesin virtual berbasis Debian ini memuat semua alat pengembangan yang akan Anda perlukan. Layanan ini menawarkan direktori beranda tetap sebesar 5 GB dan beroperasi di Google Cloud, sehingga sangat meningkatkan performa dan autentikasi jaringan. Ini berarti bahwa semua yang Anda perlukan untuk codelab ini adalah browser (ya, ini berfungsi di Chromebook).

  1. Untuk mengaktifkan Cloud Shell dari Konsol Cloud, cukup klik Aktifkan Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (hanya perlu waktu beberapa saat untuk melakukan penyediaan dan terhubung ke lingkungan).

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

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

Setelah terhubung ke Cloud Shell, Anda akan melihat bahwa Anda sudah diautentikasi dan project sudah ditetapkan ke PROJECT_ID Anda.

gcloud auth list

Output perintah

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

Output perintah

[core]
project = <PROJECT_ID>

Jika, untuk beberapa alasan, project belum disetel, cukup jalankan perintah berikut:

gcloud config set project <PROJECT_ID>

Mencari PROJECT_ID Anda? Periksa ID yang Anda gunakan di langkah-langkah penyiapan atau cari di dasbor Cloud Console:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell juga menetapkan beberapa variabel lingkungan secara default, yang mungkin berguna saat Anda menjalankan perintah di masa mendatang.

echo $GOOGLE_CLOUD_PROJECT

Output perintah

<PROJECT_ID>

Mendownload kode

Di Cloud Shell, Anda dapat mendownload kode untuk lab ini. Hal ini didasarkan pada rilis v0.1.0, jadi periksa tag tersebut:

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

Output perintah

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

Siapkan generator beban Belalang

Locust adalah framework pengujian beban Python yang berguna untuk menguji endpoint REST API. Dalam codelab ini, kami memiliki 2 uji beban yang berbeda di ‘generator' yang akan kita soroti:

  • authentication_server.py: berisi tugas untuk membuat pemain, dan mendapatkan pemain acak yang meniru pencarian titik tunggal.
  • match_server.py: berisi tugas untuk membuat game dan menutup game. Membuat game akan menetapkan 100 pemain acak yang saat ini tidak bermain game. Game penutup akan memperbarui statistik games_played dan games_won, serta memungkinkan pemain tersebut ditetapkan untuk game mendatang.

Agar Locust berjalan di Cloud Shell, Anda memerlukan Python 3.7 atau versi yang lebih tinggi. Cloud Shell dilengkapi dengan Python 3.9, jadi Anda tidak perlu melakukan apa pun selain memvalidasi versinya:

python -V

Output perintah

Python 3.9.12

Sekarang, Anda dapat menginstal persyaratan untuk Locust.

pip3 install -r requirements.txt

Output perintah

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

Sekarang, update PATH sehingga biner locust yang baru diinstal dapat ditemukan:

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

Output perintah

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

Ringkasan

Pada langkah ini, Anda telah menyiapkan project jika belum memilikinya, mengaktifkan cloud shell, dan mendownload kode untuk lab ini.

Terakhir, Anda akan menyiapkan Locust untuk pembuatan beban nanti di lab.

Berikutnya

Selanjutnya, Anda akan menyiapkan instance dan database Cloud Spanner.

3. Membuat instance dan database Spanner

Membuat instance Spanner

Pada langkah ini, kita akan menyiapkan Instance Spanner untuk codelab. Telusuri entri Spanner 1a6580bd3d3e6783.png di kiri atas Menu Tiga Garis 3129589f7bc9e5ce.png atau telusuri Spanner dengan menekan "/" dan ketik "Spanner"

36e52f8df8e13b99.png

Selanjutnya, klik 95269e75bc8c3e4d.png dan isi formulir dengan memasukkan nama instance cloudspanner-gaming untuk instance Anda, memilih konfigurasi (pilih instance regional seperti us-central1), dan tetapkan jumlah node. Untuk codelab ini, kita hanya akan memerlukan 500 processing units.

Terakhir, namun tidak kalah penting, klik "Buat" dan dalam beberapa detik Anda sudah memiliki instance Cloud Spanner.

4457c324c94f93e6.pngS

Membuat database dan skema

Setelah instance berjalan, Anda dapat membuat database. Spanner memungkinkan beberapa database pada satu instance.

{i>Database<i} adalah tempat Anda menentukan skema. Anda juga dapat mengontrol siapa saja yang memiliki akses ke database, menyiapkan enkripsi kustom, mengonfigurasi pengoptimal, dan menetapkan periode retensi.

Di instance multi-regional, Anda juga dapat mengonfigurasi pemimpin default. Baca lebih lanjut tentang database di Spanner.

Untuk codelab ini, Anda akan membuat database dengan opsi default, dan menyediakan skema pada waktu pembuatan.

Lab ini akan membuat dua tabel: pemain dan game.

77651ac12e47fe2a.pngS

Pemain dapat berpartisipasi dalam banyak game dari waktu ke waktu, tetapi hanya satu game dalam satu waktu. Pemain juga memiliki stats sebagai jenis data JSON untuk memantau statistik yang menarik seperti games_played dan games_won. Karena statistik lain mungkin ditambahkan nanti, kolom ini sebenarnya adalah kolom tanpa skema untuk pemain.

Game melacak pemain yang berpartisipasi menggunakan jenis data ARRAY Spanner. Atribut pemenang dan atribut selesai dalam game tidak akan diisi hingga game ditutup.

Ada satu kunci asing untuk memastikan current_game pemain adalah game yang valid.

Sekarang buat database dengan mengklik 'Create Database' dalam ringkasan instance:

a820db6c4a4d6f2d.png

Dan kemudian isi detailnya. Opsi penting adalah nama {i>database<i} dan dialeknya. Dalam contoh ini, kita menamai database sample-game dan memilih dialek SQL Standar Google.

Untuk skemanya, salin dan tempel DDL ini ke dalam kotak:

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

Kemudian, klik tombol create dan tunggu beberapa detik hingga database Anda selesai dibuat.

Halaman buat database akan terlihat seperti ini:

d39d358dc7d32939.png

Sekarang, Anda perlu menetapkan beberapa variabel lingkungan di Cloud Shell yang akan digunakan nanti di codelab. Jadi, catat instance-id, lalu tetapkan INSTANCE_ID dan DATABASE_ID di Cloud Shell

f6f98848d3aea9c.png

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

Ringkasan

Pada langkah ini, Anda telah membuat instance Spanner dan database sample-game. Anda juga telah menentukan skema yang digunakan game contoh ini.

Berikutnya

Selanjutnya, Anda akan men-deploy layanan profil agar pemain dapat mendaftar untuk memainkan game!

4. Men-deploy layanan profil

Ringkasan layanan

Layanan profil adalah REST API yang ditulis dalam Go yang memanfaatkan framework gin.

4fce45ee6c858b3e.pngS

Di API ini, pemain dapat mendaftar untuk bermain game. Ini dibuat dengan perintah POST sederhana yang menerima nama pemain, email, dan sandi. Sandi dienkripsi dengan bcrypt dan hash-nya disimpan dalam database.

Email diperlakukan sebagai ID unik, sedangkan player_name digunakan untuk tujuan tampilan pada game.

API ini saat ini tidak menangani login, tetapi mengimplementasikannya bisa diserahkan kepada Anda sebagai latihan tambahan.

File ./src/golang/profile-service/main.go untuk layanan profil mengekspos dua endpoint utama sebagai berikut:

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

Dan kode untuk endpoint tersebut akan diarahkan ke model player.

func getPlayerByID(c *gin.Context) {
   var playerUUID = c.Param("id")

   ctx, client := getSpannerConnection(c)

   player, err := models.GetPlayerByUUID(ctx, client, playerUUID)
   if err != nil {
       c.IndentedJSON(http.StatusNotFound, gin.H{"message": "player not found"})
       return
   }

   c.IndentedJSON(http.StatusOK, player)
}

func createPlayer(c *gin.Context) {
   var player models.Player

   if err := c.BindJSON(&player); err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   ctx, client := getSpannerConnection(c)
   err := player.AddPlayer(ctx, client)
   if err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   c.IndentedJSON(http.StatusCreated, player.PlayerUUID)
}

Salah satu hal pertama yang dilakukan layanan ini adalah menetapkan koneksi Spanner. Hal ini diterapkan pada tingkat layanan untuk membuat kumpulan sesi bagi layanan.

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 dan PlayerStats adalah struktur yang ditentukan sebagai berikut:

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

Fungsi untuk menambahkan pemain memanfaatkan penyisipan DML di dalam transaksi ReadWrite, karena menambahkan pemain adalah pernyataan tunggal, bukan penyisipan batch. Fungsinya akan terlihat seperti ini:

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
}

Untuk mengambil pemain berdasarkan UUID-nya, pembacaan sederhana diterbitkan. Tindakan ini akan mengambil playerUUID, player_name, email, dan stats pemain.

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
}

Secara default, layanan dikonfigurasi menggunakan variabel lingkungan. Lihat bagian yang relevan pada file ./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
}

Anda dapat melihat bahwa perilaku default-nya adalah menjalankan layanan di localhost:8080.

Dengan informasi ini, sekarang saatnya menjalankan layanan.

Menjalankan layanan profil

Jalankan layanan menggunakan perintah go. Tindakan ini akan mendownload dependensi, dan membuat layanan yang berjalan di port 8080:

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

Output perintah:

[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

Uji layanan dengan mengeluarkan perintah curl:

curl http://localhost:8080/players \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"email": "test@gmail.com","password": "s3cur3P@ss","player_name": "Test Player"}'

Output perintah:

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"

Ringkasan

Pada langkah ini, Anda men-deploy layanan profil yang memungkinkan pemain mendaftar untuk memainkan game, dan menguji layanan dengan mengeluarkan panggilan POST api untuk membuat pemain baru.

Langkah Berikutnya

Pada langkah berikutnya, Anda akan men-deploy layanan pencocokan.

5. Men-deploy layanan pencocokan

Ringkasan layanan

Layanan pencocokan adalah REST API yang ditulis dalam Go yang memanfaatkan framework gin.

9aecd571df0dcd7c.pngS

Dalam API ini, game dibuat dan ditutup. Saat game dibuat, 10 pemain yang sedang tidak memainkan game akan ditetapkan ke game tersebut.

Saat game ditutup, pemenang dipilih secara acak dan setiap pemain statistik untuk games_played dan games_won disesuaikan. Selain itu, setiap pemain diupdate untuk menunjukkan bahwa mereka tidak lagi bermain sehingga dapat memainkan game mendatang.

File ./src/golang/matchmaking-service/main.go untuk layanan pencarian lawan mengikuti penyiapan dan kode yang serupa dengan layanan profile, sehingga tidak diulang di sini. Layanan ini mengekspos dua endpoint utama sebagai berikut:

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

Layanan ini menyediakan struct Game, serta struktur Player dan PlayerStats yang diringkas:

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

Untuk membuat game, layanan matchmaking mengambil pilihan acak dari 100 pemain yang saat ini sedang tidak memainkan game.

Mutasi Spanner dipilih untuk membuat game dan menetapkan pemain, karena mutasi lebih berperforma tinggi daripada DML untuk perubahan besar.

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

Pemilihan pemain secara acak dilakukan dengan SQL menggunakan kemampuan RESERVOIR TABLESPACE dari GoogleSQL.

Menutup game sedikit lebih rumit. Metode ini melibatkan pemilihan pemenang acak di antara para pemain game, menandai waktu game selesai, dan memperbarui statistik untuk games_played dan games_won.

Karena kompleksitas dan jumlah perubahan ini, mutasi dipilih kembali untuk menutup game.

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
}

Konfigurasi ditangani kembali melalui variabel lingkungan seperti yang dijelaskan dalam ./src/golang/matchmaking-service/config/config.go untuk layanan tersebut.

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

Untuk menghindari konflik dengan layanan profil, layanan ini berjalan di localhost:8081 secara default.

Dengan informasi ini, sekarang saatnya menjalankan layanan pencocokan.

Menjalankan layanan pencocokan

Jalankan layanan menggunakan perintah go. Tindakan ini akan membuat layanan berjalan di port 8082. Layanan ini memiliki banyak dependensi yang sama seperti layanan profil, sehingga dependensi baru tidak akan didownload.

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

Output perintah:

[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

Buat game

Uji layanan untuk membuat game. Pertama, buka terminal baru di Cloud Shell:

90eceac76a6bb90b.pngS

Kemudian, berikan perintah curl berikut:

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

Output perintah:

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"

Tutup game

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

Output perintah:

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"

Ringkasan

Pada langkah ini, Anda telah men-deploy layanan pencari jodoh untuk menangani pembuatan game dan menugaskan pemain ke game tersebut. Layanan ini juga menangani penutupan game, yang memilih pemenang acak dan memperbarui semua statistik untuk games_played dan games_won.

Langkah Berikutnya

Setelah layanan Anda berjalan, saatnya membuat pemain mendaftar dan bermain game!

6. Mulai putar

Setelah layanan profil dan perjodohan berjalan, kamu bisa membuat beban menggunakan generator locust yang disediakan.

Locust menawarkan antarmuka web untuk menjalankan generator, tetapi di lab ini Anda akan menggunakan command line (opsi –headless).

Daftarkan pemain

Pertama, Anda harus menghasilkan pemain.

Kode python untuk membuat pemain dalam file ./generators/authentication_server.py akan terlihat seperti ini:

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

Nama, email, dan sandi pemain dibuat secara acak.

Pemain yang berhasil mendaftar akan diambil oleh tugas kedua untuk membuat beban baca.

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

Perintah berikut memanggil file ./generators/authentication_server.py yang akan menghasilkan pemain baru selama 30 detik (t=30s) dengan dua thread sekaligus (u=2):

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

Pemain bergabung ke game

Setelah pemain mendaftar, mereka ingin mulai bermain game!

Kode python untuk membuat dan menutup game dalam file ./generators/match_server.py akan terlihat seperti ini:

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)

Ketika dijalankan, generator akan membuka dan menutup game dengan rasio 2:1 (open:close). Perintah ini akan menjalankan generator selama 10 detik (-t=10s):

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

Ringkasan

Pada langkah ini, Anda menyimulasikan pemain yang mendaftar untuk bermain game, lalu menjalankan simulasi agar pemain dapat bermain game menggunakan layanan pencarian lawan. Simulasi ini memanfaatkan framework Python Locust untuk mengajukan permintaan ke layanan kami REST API.

Jangan ragu untuk mengubah waktu yang dihabiskan untuk membuat pemain dan bermain game, serta jumlah pengguna serentak (-u).

Langkah Berikutnya

Setelah simulasi, Anda perlu memeriksa berbagai statistik dengan membuat kueri Spanner.

7. Mengambil statistik game

Setelah kami memiliki simulasi pemain yang dapat mendaftar dan bermain game, Anda harus memeriksa statistik Anda.

Untuk melakukannya, gunakan Konsol Cloud untuk mengeluarkan permintaan kueri ke Spanner.

b5e3154c6f7cb0cf.png

Memeriksa pertandingan terbuka vs tertutup

Game tertutup adalah game yang stempel waktu selesai telah diisi, sedangkan game terbuka akan selesai menjadi NULL. Nilai ini ditetapkan saat game ditutup.

Kueri ini akan membantu Anda melihat berapa banyak {i>game<i} yang terbuka dan berapa banyak yang ditutup:

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
)

Hasil:

Type

NumGames

Open Games

0

Closed Games

175

Memeriksa jumlah pemain yang bermain vs tidak bermain

Pemain memainkan game jika kolom current_game miliknya disetel. Jika tidak, dia sedang tidak bermain game.

Jadi untuk membandingkan berapa banyak pemain yang sedang bermain dan tidak bermain, gunakan kueri ini:

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
)

Hasil:

Type

NumPlayers

Playing

0

Not Playing

310

Menentukan pemenang teratas

Saat game ditutup, salah satu pemain dipilih secara acak untuk menjadi pemenang. Statistik games_won pemain bertambah saat menutup game.

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

Hasil:

playerUUID

stats

07e247c5-f88e-4bca-a7bc-12d2485f2f2b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

09b72595-40af-4406-a000-2fb56c58fe92

{&quot;games_played&quot;:56,&quot;games_won&quot;:1}

1002385b-02a0-462b-a8e7-05c9b27223aa

{&quot;games_played&quot;:66,&quot;games_won&quot;:1}

13ec3770-7ae3-495f-9b53-6322d8e8d6c3

{&quot;games_played&quot;:44,&quot;games_won&quot;:1}

15513852-3f2a-494f-b437-fe7125d15f1b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

17faec64-4f77-475c-8df8-6ab026cf6698

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

1abfcb27-037d-446d-bb7a-b5cd17b5733d

{&quot;games_played&quot;:63,&quot;games_won&quot;:1}

2109a33e-88bd-4e74-a35c-a7914d9e3bde

{&quot;games_played&quot;:56,&quot;games_won&quot;:2}

222e37d9-06b0-4674-865d-a0e5fb80121e

{&quot;games_played&quot;:60,&quot;games_won&quot;:1}

22ced15c-0da6-4fd9-8cb2-1ffd233b3c56

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

Ringkasan

Pada langkah ini, Anda meninjau berbagai statistik pemain dan game menggunakan Konsol Cloud untuk mengkueri Spanner.

Langkah Berikutnya

Selanjutnya, saatnya melakukan pembersihan!

8. Membersihkan (opsional)

Untuk membersihkannya, cukup buka bagian Cloud Spanner di Konsol Cloud dan hapus instance 'cloudspanner-gaming' yang kita buat pada langkah codelab bernama "Menyiapkan Instance Cloud Spanner".

9. Selamat!

Selamat, Anda telah berhasil men-deploy contoh game di Spanner

Apa langkah selanjutnya?

Di lab ini, Anda telah diperkenalkan dengan berbagai topik cara menggunakan Spanner menggunakan driver golang. Studi kasus akan memberi Anda dasar yang lebih baik untuk memahami konsep penting seperti:

  • Desain skema
  • DML vs Mutasi
  • Bekerja dengan Golang

Pastikan untuk melihat codelab Post Trading Game Cloud Spanner untuk contoh lain cara menggunakan Spanner sebagai backend untuk game Anda.