1. Pengantar
Cloud Spanner adalah layanan database relasional skalabel dan terkelola sepenuhnya secara horizontal yang terdistribusi secara global, yang menyediakan transaksi ACID dan semantik SQL tanpa mengorbankan performa dan ketersediaan tinggi.
Fitur-fitur ini menjadikan Spanner sangat cocok dalam arsitektur game yang ingin mengaktifkan basis pemain global atau mengkhawatirkan konsistensi data
Dalam lab ini, Anda akan membuat dua layanan Go yang berinteraksi dengan database Spanner regional agar pemain dapat mendaftar dan mulai bermain.

Selanjutnya, Anda akan membuat data menggunakan framework pemuatan Python Locust.io untuk menyimulasikan pemain yang mendaftar dan bermain game. Kemudian, Anda akan membuat kueri Spanner untuk menentukan jumlah pemain yang bermain, dan beberapa statistik tentang jumlah pertandingan yang dimenangkan vs. jumlah pertandingan yang dimainkan pemain.
Terakhir, Anda akan membersihkan resource yang dibuat di lab ini.
Yang akan Anda build
Sebagai bagian dari lab ini, Anda akan:
- Membuat instance Spanner
- Men-deploy layanan Profil yang ditulis di Go untuk menangani pendaftaran pemain
- Deploy layanan Pencarian lawan yang ditulis dalam Go untuk menugaskan pemain ke game, menentukan pemenang, dan memperbarui statistik game pemain.
Yang akan Anda pelajari
- Cara menyiapkan instance Cloud Spanner
- Cara membuat database dan skema game
- Cara men-deploy aplikasi Go untuk bekerja 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
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:

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

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

Dialog pembuatan project berikutnya memungkinkan Anda memasukkan detail project baru:

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 tersebut 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.

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).
- Untuk mengaktifkan Cloud Shell dari Cloud Console, cukup klik Aktifkan Cloud Shell
(hanya perlu beberapa saat untuk melakukan penyediaan dan terhubung ke lingkungan).


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:

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. 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'
Menyiapkan generator beban Locust
Locust adalah framework pengujian beban Python yang berguna untuk menguji endpoint REST API. Dalam codelab ini, kita memiliki 2 uji beban yang berbeda di direktori 'generators' yang akan kita bahas:
- authentication_server.py: berisi tugas untuk membuat pemain, dan untuk mendapatkan pemain acak guna meniru pencarian satu titik.
- match_server.py: berisi tugas untuk membuat game dan menutup game. Membuat game akan menetapkan 100 pemain acak yang saat ini tidak bermain game. Menutup game akan memperbarui statistik games_played dan games_won, serta memungkinkan pemain tersebut ditugaskan ke game mendatang.
Untuk menjalankan Locust di Cloud Shell, Anda memerlukan Python 3.7 atau yang lebih tinggi. Cloud Shell dilengkapi dengan Python 3.9, jadi Anda hanya perlu 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, perbarui PATH agar 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 di lab nanti.
Berikutnya
Selanjutnya, Anda akan menyiapkan instance dan database Cloud Spanner.
3. Membuat instance dan database Spanner
Buat instance Spanner
Pada langkah ini, kita menyiapkan Instance Spanner untuk codelab. Telusuri entri Spanner
di kiri atas Menu Tiga Garis
atau telusuri Spanner dengan menekan "/" dan ketik "Spanner"

Selanjutnya, klik
dan isi formulir dengan memasukkan nama instance cloudspanner-gaming untuk instance Anda, memilih konfigurasi (pilih instance regional seperti us-central1), dan menetapkan jumlah node. Untuk codelab ini, kita hanya memerlukan 500 processing units.
Terakhir, namun tidak kalah penting, klik "Buat" dan dalam beberapa detik Anda sudah memiliki instance Cloud Spanner.

Membuat database dan skema
Setelah instance Anda berjalan, Anda dapat membuat database. Spanner memungkinkan beberapa database dalam satu instance.
Database adalah tempat Anda menentukan skema. Anda juga dapat mengontrol siapa yang memiliki akses ke database, menyiapkan enkripsi kustom, mengonfigurasi pengoptimal, dan menetapkan periode retensi.
Pada instance multi-regional, Anda juga dapat mengonfigurasi pemimpin default. Baca selengkapnya tentang database di Spanner.
Untuk codelab ini, Anda akan membuat database dengan opsi default, dan menyediakan skema pada saat pembuatan.
Lab ini akan membuat dua tabel: players dan games.

Pemain dapat berpartisipasi dalam banyak game dari waktu ke waktu, tetapi hanya satu game dalam satu waktu. Pemain juga memiliki statistik sebagai jenis data JSON untuk melacak statistik menarik seperti games_played dan games_won. Karena statistik lain dapat ditambahkan nanti, kolom ini secara efektif merupakan kolom tanpa skema untuk pemain.
Game melacak pemain yang berpartisipasi menggunakan jenis data ARRAY Spanner. Atribut pemenang dan selesai 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' di ringkasan instance:

Kemudian, isi detailnya. Opsi pentingnya adalah nama database dan dialek. Dalam contoh ini, kami menamai database sample-game dan memilih dialek Google Standard SQL.
Untuk skema, 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 buat dan tunggu beberapa detik hingga database Anda dibuat.
Halaman pembuatan database akan terlihat seperti ini:

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

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 oleh contoh game ini.
Berikutnya
Selanjutnya, Anda akan men-deploy layanan profil agar pemain dapat mendaftar untuk bermain game.
4. Men-deploy layanan profil
Ringkasan layanan
Layanan profil adalah REST API yang ditulis dalam Go yang memanfaatkan framework gin.

Dalam API ini, pemain dapat mendaftar untuk bermain game. Akun ini dibuat dengan perintah POST sederhana yang menerima nama, email, dan sandi pemain. Sandi dienkripsi dengan bcrypt dan hash disimpan dalam database.
Email dianggap sebagai ID unik, sedangkan player_name digunakan untuk tujuan tampilan game.
Saat ini, API ini tidak menangani login, tetapi Anda dapat mengimplementasikannya 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())
}
Kode untuk endpoint tersebut akan dirutekan 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 menyetel koneksi Spanner. Hal ini diterapkan di tingkat layanan untuk membuat kumpulan sesi untuk 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 struct 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 satu pernyataan, 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, perintah baca sederhana akan dikeluarkan. 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 dari 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 defaultnya adalah menjalankan layanan di localhost:8080.
Dengan informasi ini, saatnya menjalankan layanan.
Menjalankan layanan profil
Jalankan layanan menggunakan perintah go. Tindakan ini akan mendownload dependensi, dan membuat layanan 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 menjalankan 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 bermain game Anda, dan Anda menguji layanan tersebut dengan mengeluarkan panggilan API POST untuk membuat pemain baru.
Langkah Berikutnya
Pada langkah berikutnya, Anda akan men-deploy layanan pencocokan.
5. Men-deploy layanan perjodohan
Ringkasan layanan
Layanan pencocokan adalah REST API yang ditulis dalam Go yang memanfaatkan framework gin.

Di API ini, game dibuat dan ditutup. Saat game dibuat, 10 pemain yang saat ini tidak bermain game akan ditetapkan ke game tersebut.
Saat game ditutup, pemenang akan dipilih secara acak dan statistik setiap pemain untuk games_played dan games_won akan disesuaikan. Selain itu, setiap pemain diperbarui untuk menunjukkan bahwa mereka tidak lagi bermain dan tersedia untuk bermain game di masa mendatang.
File ./src/golang/matchmaking-service/main.go untuk layanan matchmaking 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 struct Player dan PlayerStats yang lebih kecil:
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 100 pemain yang dipilih secara acak yang saat ini tidak bermain 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 TABLESPACE RESERVOIR GoogleSQL.
Menutup game sedikit lebih rumit. Proses ini melibatkan pemilihan pemenang secara acak di antara para pemain game, menandai waktu game selesai, dan memperbarui statistik setiap pemain untuk games_played dan games_won.
Karena kompleksitas dan jumlah perubahan ini, mutasi dipilih lagi 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 lagi melalui variabel lingkungan seperti yang dijelaskan dalam ./src/golang/matchmaking-service/config/config.go untuk layanan.
// 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 profile-service, layanan ini berjalan di localhost:8081 secara default.
Dengan informasi ini, sekarang saatnya menjalankan layanan pencocokan.
Menjalankan layanan pencarian jodoh
Jalankan layanan menggunakan perintah go. Tindakan ini akan membuat layanan berjalan di port 8082. Layanan ini memiliki banyak dependensi yang sama dengan profile-service, 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
Membuat game
Uji layanan untuk membuat game. Pertama, buka terminal baru di Cloud Shell:

Kemudian, jalankan 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 men-deploy matchmaking-service untuk menangani pembuatan game dan menetapkan pemain ke game tersebut. Layanan ini juga menangani penutupan game, yang memilih pemenang secara acak dan memperbarui semua statistik pemain game untuk games_played dan games_won.
Langkah Berikutnya
Setelah layanan Anda berjalan, saatnya membuat pemain mendaftar dan bermain game.
6. Mulai bermain
Setelah layanan profil dan matchmaking berjalan, Anda dapat membuat beban menggunakan generator locust yang disediakan.
Locust menawarkan antarmuka web untuk menjalankan generator, tetapi dalam lab ini Anda akan menggunakan command line (opsi –headless).
Mendaftarkan pemain
Pertama, Anda harus membuat pemain.
Kode python untuk membuat pemain di file ./generators/authentication_server.py 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 menghasilkan 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 membuat pemain baru selama 30 detik (t=30s) dengan serentak 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 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)
Saat generator ini dijalankan, game akan dibuka dan ditutup dengan rasio 2:1 (buka:tutup). 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 matchmaking. Simulasi ini memanfaatkan framework Locust Python untuk mengirimkan permintaan ke REST API layanan kami.
Anda dapat mengubah waktu yang dihabiskan untuk membuat pemain dan bermain game, serta jumlah pengguna serentak (-u).
Langkah Berikutnya
Setelah simulasi, Anda dapat memeriksa berbagai statistik dengan mengkueri Spanner.
7. Mengambil statistik game
Setelah menyimulasikan pemain yang dapat mendaftar dan bermain game, Anda harus memeriksa statistik.
Untuk melakukannya, gunakan Konsol Cloud untuk mengirimkan permintaan kueri ke Spanner.

Memeriksa game yang terbuka vs. tertutup
Game yang ditutup adalah game yang memiliki stempel waktu selesai, sedangkan game yang terbuka akan memiliki selesai yang bernilai NULL. Nilai ini ditetapkan saat game ditutup.
Jadi, kueri ini akan memungkinkan Anda memeriksa berapa banyak game yang terbuka dan berapa banyak yang tertutup:
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:
|
|
|
|
|
|
Memeriksa jumlah pemain yang bermain vs. tidak bermain
Pemain sedang bermain game jika kolom current_game miliknya ditetapkan. Jika tidak, mereka tidak sedang bermain game.
Jadi, untuk membandingkan jumlah pemain yang saat ini 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:
|
|
|
|
|
|
Menentukan pemenang teratas
Saat game ditutup, salah satu pemain akan dipilih secara acak sebagai pemenang. Statistik games_won pemain tersebut akan bertambah saat keluar dari 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 | {"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} |
Ringkasan
Pada langkah ini, Anda telah meninjau berbagai statistik pemain dan game menggunakan Konsol Cloud untuk membuat kueri Spanner.
Langkah Berikutnya
Selanjutnya, saatnya membersihkan!
8. Membersihkan (opsional)
Untuk membersihkan, cukup buka bagian Cloud Spanner di Cloud Console dan hapus instance ‘cloudspanner-gaming' yang kita buat di langkah codelab bernama "Siapkan Instance Cloud Spanner".
9. Selamat!
Selamat, Anda telah berhasil men-deploy game contoh di Spanner
Apa langkah selanjutnya?
Di lab ini, Anda telah diperkenalkan dengan berbagai topik terkait penggunaan Spanner menggunakan driver golang. Dengan begitu, Anda akan lebih memahami konsep penting seperti:
- Desain skema
- DML vs. Mutasi
- Bekerja dengan Golang
Pastikan untuk melihat codelab Pusat Jual Beli Game Cloud Spanner untuk contoh lain cara menggunakan Spanner sebagai backend untuk game Anda.