1. Giriş
Cloud Spanner, performanstan ve yüksek kullanılabilirlikten ödün vermeden ACID işlemleri ve SQL anlamları sağlayan, tümüyle yönetilen, yatay olarak ölçeklenebilir, küresel olarak dağıtılmış bir ilişkisel veritabanı hizmetidir.
Bu özellikler, Spanner'ı küresel bir oyuncu tabanı oluşturmak isteyen veya veri tutarlılığı konusunda endişeleri olan oyunların mimarisi için ideal bir seçenek haline getirir.
Bu laboratuvarda, oyuncuların kaydolup oynamaya başlamasını sağlamak için bölgesel bir Spanner veritabanıyla etkileşime giren iki Go hizmeti oluşturacaksınız.

Ardından, oyuncuların kaydolmasını ve oyunu oynamasını simüle etmek için Python yükleme çerçevesi Locust.io'dan yararlanarak veri oluşturacaksınız. Ardından, kaç oyuncunun oynadığını ve oyuncuların kazandığı maçlar ile oynadığı maçlarla ilgili bazı istatistikleri belirlemek için Spanner'ı sorgularsınız.
Son olarak, bu laboratuvarda oluşturulan kaynakları temizleyeceksiniz.
Ne oluşturacaksınız?
Bu laboratuvar kapsamında şunları yapacaksınız:
- Spanner örneği oluşturma
- Oyuncu kaydını işlemek için Go ile yazılmış bir Profil hizmeti dağıtın
- Oyuncuları oyunlara atamak, kazananları belirlemek ve oyuncuların oyun istatistiklerini güncellemek için Go ile yazılmış bir eşleştirme hizmeti dağıtın.
Neler öğreneceksiniz?
- Cloud Spanner örneği oluşturma
- Oyun veritabanı ve şeması oluşturma
- Cloud Spanner ile çalışacak Go uygulamalarını dağıtma
- Locust kullanarak veri oluşturma
- Oyunlar ve oyuncularla ilgili soruları yanıtlamak için Cloud Spanner'daki verileri sorgulama
İhtiyacınız olanlar
2. Kurulum ve şartlar
Proje oluşturma
Google Hesabınız (Gmail veya Google Apps) yoksa hesap oluşturmanız gerekir. Google Cloud Platform Console'da ( console.cloud.google.com) oturum açın ve yeni bir proje oluşturun.
Önceden oluşturduğunuz bir projeniz varsa konsolun sol üst kısmındaki proje seçimi açılır menüsünü tıklayın:

ve yeni bir proje oluşturmak için açılan iletişim kutusunda "YENİ PROJE" düğmesini tıklayın:

Henüz bir projeniz yoksa ilk projenizi oluşturmak için aşağıdaki gibi bir iletişim kutusu görürsünüz:

Sonraki proje oluşturma iletişim kutusunda yeni projenizin ayrıntılarını girebilirsiniz:

Tüm Google Cloud projelerinde benzersiz bir ad olan proje kimliğini unutmayın (Yukarıdaki ad zaten alınmış olduğundan sizin için çalışmayacaktır). Bu codelab'in ilerleyen bölümlerinde PROJECT_ID olarak adlandırılacaktır.
Ardından, henüz yapmadıysanız Google Cloud kaynaklarını kullanmak için Developers Console'da faturalandırmayı etkinleştirmeniz ve Cloud Spanner API'yi etkinleştirmeniz gerekir.

Bu codelab'i tamamlamak size birkaç dolardan fazla maliyet getirmemelidir. Ancak daha fazla kaynak kullanmaya veya kaynakları çalışır durumda bırakmaya karar verirseniz maliyet artabilir (bu belgenin sonundaki "temizleme" bölümüne bakın). Google Cloud Spanner fiyatlandırması burada belgelenmiştir.
Google Cloud Platform'un yeni kullanıcıları, bu codelab'i tamamen ücretsiz hale getirecek 300 ABD doları değerinde ücretsiz deneme sürümünden yararlanabilir.
Google Cloud Shell Kurulumu
Google Cloud ve Spanner, dizüstü bilgisayarınızdan uzaktan çalıştırılabilir ancak bu codelab'de Cloud'da çalışan bir komut satırı ortamı olan Google Cloud Shell'i kullanacağız.
Bu Debian tabanlı sanal makine, ihtiyaç duyacağınız tüm geliştirme araçlarını içerir. 5 GB boyutunda kalıcı bir ana dizin bulunur ve Google Cloud'da çalışır. Bu sayede ağ performansı ve kimlik doğrulama önemli ölçüde güçlenir. Bu nedenle, bu codelab için ihtiyacınız olan tek şey bir tarayıcıdır (Chromebook'ta da çalışır).
- Cloud Shell'i Cloud Console'dan etkinleştirmek için Cloud Shell'i Etkinleştir'i
tıklamanız yeterlidir (ortamın sağlanması ve bağlantının kurulması yalnızca birkaç saniye sürer).


Cloud Shell'e bağlandıktan sonra kimliğinizin doğrulandığını ve projenin, PROJECT_ID'nize ayarlandığını görürsünüz.
gcloud auth list
Komut çıkışı
Credentialed accounts:
- <myaccount>@<mydomain>.com (active)
gcloud config list project
Komut çıkışı
[core]
project = <PROJECT_ID>
Herhangi bir nedenle proje ayarlanmamışsa şu komutu verin:
gcloud config set project <PROJECT_ID>
PROJECT_ID'nizi mi arıyorsunuz? Kurulum adımlarında hangi kimliği kullandığınızı kontrol edin veya Cloud Console kontrol panelinde arayın:

Cloud Shell, gelecekteki komutları çalıştırırken faydalı olabilecek bazı ortam değişkenlerini de varsayılan olarak ayarlar.
echo $GOOGLE_CLOUD_PROJECT
Komut çıkışı
<PROJECT_ID>
Kodu indirme
Cloud Shell'de bu laboratuvarın kodunu indirebilirsiniz. Bu, v0.1.0 sürümüne dayanmaktadır. Bu nedenle, etiketi kontrol edin:
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
Komut çıkışı
Cloning into 'spanner-gaming-sample'...
*snip*
Switched to a new branch 'v0.1.0-branch'
Locust yük oluşturucuyu kurma
Locust, REST API uç noktalarını test etmek için yararlı olan bir Python yük testi çerçevesidir. Bu codelab'de, "generators" dizininde vurgulayacağımız 2 farklı yük testi vardır:
- authentication_server.py: Oyuncu oluşturma ve tek noktalı aramaları taklit etmek için rastgele bir oyuncu alma görevlerini içerir.
- match_server.py: Oyun oluşturma ve oyunları kapatma görevlerini içerir. Oyun oluşturduğunuzda, şu anda oyun oynamayan 100 rastgele oyuncu atanır. Kapatılan oyunlar, oynanan oyunlar ve kazanılan oyunlar istatistiklerini günceller ve bu oyuncuların gelecekteki bir oyuna atanmasına olanak tanır.
Locust'u Cloud Shell'de çalıştırmak için Python 3.7 veya sonraki bir sürümü kullanmanız gerekir. Cloud Shell'de Python 3.9 yüklü olduğundan sürümü doğrulamanız yeterlidir:
python -V
Komut çıkışı
Python 3.9.12
Şimdi Locust için gerekenleri yükleyebilirsiniz.
pip3 install -r requirements.txt
Komut çıkışı
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
Şimdi, yeni yüklenen locust ikili programının bulunabilmesi için PATH'i güncelleyin:
PATH=~/.local/bin":$PATH"
which locust
Komut çıkışı
/home/<user>/.local/bin/locust
Özet
Bu adımda, projeniz yoksa projenizi oluşturup Cloud Shell'i etkinleştirdiniz ve bu laboratuvarın kodunu indirdiniz.
Son olarak, laboratuvarın ilerleyen bölümlerinde yük oluşturmak için Locust'u ayarlarsınız.
Sıradaki
Ardından Cloud Spanner örneğini ve veritabanını ayarlayacaksınız.
3. Spanner örneği ve veritabanı oluşturma
Spanner örneğini oluşturma
Bu adımda, Spanner örneğimizi kod laboratuvarı için ayarlıyoruz. Sol üstteki hamburger menüsünde
Spanner girişini arayın
veya "/" tuşuna basıp "Spanner" yazarak Spanner'ı arayın.

Ardından
simgesini tıklayın ve örneğiniz için örnek adını cloudspanner-gaming girerek, bir yapılandırma seçerek (us-central1 gibi bölgesel bir örnek seçin) ve düğüm sayısını ayarlayarak formu doldurun. Bu codelab için yalnızca 500 processing units gerekir.
Son olarak "Oluştur"u tıkladığınızda saniyeler içinde Cloud Spanner örneğini kullanmaya başlayabilirsiniz.

Veritabanı ve şema oluşturma
Örneğiniz çalıştıktan sonra veritabanını oluşturabilirsiniz. Spanner, tek bir örnekte birden fazla veritabanına izin verir.
Şemanızı tanımladığınız yer veritabanıdır. Ayrıca veritabanına kimlerin erişebileceğini kontrol edebilir, özel şifreleme ayarlayabilir, optimize ediciyi yapılandırabilir ve saklama süresini belirleyebilirsiniz.
Çok bölgeli örneklerde varsayılan lideri de yapılandırabilirsiniz. Spanner'daki veritabanları hakkında daha fazla bilgi edinin.
Bu kod laboratuvarında, veritabanını varsayılan seçeneklerle oluşturacak ve şemayı oluşturma sırasında sağlayacaksınız.
Bu laboratuvarda players ve games olmak üzere iki tablo oluşturulacak.

Oyuncular zaman içinde birçok oyuna katılabilir ancak aynı anda yalnızca bir oyuna katılabilir. Oyuncular, oynanan_maç ve kazanılan_maç gibi ilginç istatistikleri takip etmek için JSON veri türü olarak istatistiklere de sahiptir. Daha sonra başka istatistikler eklenebileceğinden bu sütun, oyuncular için şemasız bir sütun olarak kabul edilir.
Oyunlar, Spanner'ın ARRAY veri türünü kullanarak katılımcı oyuncuları takip eder. Bir maçın kazananı ve tamamlanmış özellikleri, maç sona erene kadar doldurulmaz.
Oyuncunun current_game değerinin geçerli bir oyun olduğundan emin olmak için bir yabancı anahtar vardır.
Şimdi örnek genel bakışında "Veritabanı Oluştur"u tıklayarak veritabanını oluşturun:

Ardından ayrıntıları girin. Önemli seçenekler veritabanı adı ve lehçedir. Bu örnekte, veritabanına sample-game adını verdik ve Google Standart SQL dilini seçtik.
Şema için bu DDL'yi kopyalayıp kutuya yapıştırın:
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);
Ardından, oluştur düğmesini tıklayın ve veritabanınızın oluşturulması için birkaç saniye bekleyin.
Veritabanı oluşturma sayfası aşağıdaki gibi görünmelidir:

Şimdi, kod laboratuvarında daha sonra kullanılmak üzere Cloud Shell'de bazı ortam değişkenlerini ayarlamanız gerekiyor. Bu nedenle, örnek kimliğini not edin ve INSTANCE_ID ile DATABASE_ID'yi Cloud Shell'de ayarlayın.

export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
Özet
Bu adımda bir Spanner örneği ve sample-game veritabanı oluşturdunuz. Bu örnek oyunun kullandığı şemayı da tanımlamış olmanız gerekir.
Sıradaki
Ardından, oyuncuların oyuna kaydolmasına izin vermek için profil hizmetini dağıtacaksınız.
4. Profil hizmetini dağıtma
Hizmete genel bakış
Profil hizmeti, Gin çerçevesinden yararlanan ve Go ile yazılmış bir REST API'dir.

Bu API'de oyuncular oyun oynamak için kaydolabilir. Bu, oyuncu adı, e-posta ve şifre kabul eden basit bir POST komutuyla oluşturulur. Şifre, bcrypt ile şifrelenir ve karma, veritabanında depolanır.
E-posta benzersiz tanımlayıcı olarak kabul edilirken player_name, oyunda görüntüleme amacıyla kullanılır.
Bu API şu anda oturum açma işlemini gerçekleştirmiyor ancak bu işlevi uygulamak ek bir alıştırma olarak size bırakılmıştır.
Profil hizmeti için ./src/golang/profile-service/main.go dosyası, iki temel uç noktayı aşağıdaki şekilde kullanıma sunar:
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())
}
Bu uç noktaların kodu oynatıcı modeline yönlendirilir.
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)
}
Hizmetin yaptığı ilk işlemlerden biri Spanner bağlantısını ayarlamaktır. Bu, hizmet için oturum havuzu oluşturmak üzere hizmet düzeyinde uygulanır.
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 ve PlayerStats, aşağıdaki gibi tanımlanan yapılardır:
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"`
}
Oyuncu ekleme işlevi, ReadWrite işlemi içinde bir DML ekleme işleminden yararlanır. Bunun nedeni, oyuncu eklemenin toplu ekleme yerine tek bir ifade olmasıdır. İşlev şu şekilde görünür:
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
}
UUID'sine göre bir oyuncuyu almak için basit bir okuma işlemi yapılır. Bu işlem, oyuncunun playerUUID, player_name,email ve stats bilgilerini alır.
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
}
Hizmet varsayılan olarak ortam değişkenleri kullanılarak yapılandırılır. ./src/golang/profile-service/config/config.go dosyasının ilgili bölümüne bakın.
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
}
Varsayılan davranışın hizmeti localhost:8080 üzerinde çalıştırmak olduğunu görebilirsiniz.
Bu bilgilerle hizmeti çalıştırma zamanı geldi.
Profil hizmetini çalıştırma
Hizmeti go komutunu kullanarak çalıştırın. Bu işlem, bağımlılıkları indirir ve 8080 numaralı bağlantı noktasında çalışan hizmeti oluşturur:
cd ~/spanner-gaming-sample/src/golang/profile-service
go run . &
Komut çıkışı:
[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
Bir curl komutu vererek hizmeti test edin:
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"}'
Komut çıkışı:
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"
Özet
Bu adımda, oyuncuların oyununuza kaydolmasına olanak tanıyan profil hizmetini dağıttınız ve yeni bir oyuncu oluşturmak için POST API çağrısı yaparak hizmeti test ettiniz.
Sonraki Adımlar
Sonraki adımda, eşleştirme hizmetini dağıtacaksınız.
5. Çöpçatanlık hizmetini dağıtın
Hizmete genel bakış
Eşleştirme hizmeti, gin çerçevesinden yararlanan ve Go ile yazılmış bir REST API'dir.

Bu API'de oyunlar oluşturulur ve kapatılır. Bir oyun oluşturulduğunda, o anda oyun oynamayan 10 oyuncu oyuna atanır.
Bir oyun kapatıldığında kazanan rastgele seçilir ve her oyuncunun games_played (oynanan oyunlar) ile games_won (kazanılan oyunlar) istatistikleri ayarlanır. Ayrıca, her oyuncu artık oynamadığını ve bu nedenle gelecekteki oyunlarda oynayabileceğini belirtecek şekilde güncellenir.
Eşleştirme hizmetinin ./src/golang/matchmaking-service/main.go dosyası, profile hizmetiyle benzer bir kurulum ve kodu kullandığından burada tekrarlanmamıştır. Bu hizmet, aşağıdaki gibi iki birincil uç nokta sunar:
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())
}
Bu hizmet, Game yapısının yanı sıra basitleştirilmiş Player ve PlayerStats yapıları sağlar:
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"`
}
Eşleştirme hizmeti, oyun oluşturmak için şu anda oyun oynamayan 100 oyuncuyu rastgele seçer.
Spanner mutasyonları, büyük değişiklikler için mutasyonlar DML'den daha iyi performans gösterdiğinden oyunu oluşturmak ve oyuncuları atamak için seçilir.
// 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
}
Oyuncuların rastgele seçimi, GoogleSQL'in TABLESPACE RESERVOIR özelliği kullanılarak SQL ile yapılır.
Bir oyunu kapatmak biraz daha karmaşıktır. Oyunun oyuncuları arasından rastgele bir kazanan seçilir, oyunun bitiş zamanı işaretlenir ve her oyuncunun games_played (oynanan oyunlar) ve games_won (kazanılan oyunlar) istatistikleri güncellenir.
Bu karmaşıklık ve değişikliklerin miktarı nedeniyle, oyunu kapatmak için mutasyonlar tekrar seçilir.
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
}
Yapılandırma, hizmet için ./src/golang/matchmaking-service/config/config.go dosyasında açıklandığı gibi ortam değişkenleri aracılığıyla yapılır.
// 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")
Bu hizmet, profil hizmetiyle çakışmayı önlemek için varsayılan olarak localhost:8081 üzerinde çalışır.
Bu bilgilerle birlikte, eşleştirme hizmetini çalıştırmanın zamanı geldi.
Çöpçatanlık hizmetini çalıştırma
Hizmeti go komutunu kullanarak çalıştırın. Bu işlem, 8082 numaralı bağlantı noktasında çalışan hizmeti oluşturur. Bu hizmet, profile-service ile aynı bağımlılıkların çoğuna sahip olduğundan yeni bağımlılıklar indirilmez.
cd ~/spanner-gaming-sample/src/golang/matchmaking-service
go run . &
Komut çıkışı:
[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
Oyun oluşturma
Oyun oluşturmak için hizmeti test edin. Öncelikle Cloud Shell'de yeni bir terminal açın:

Ardından, aşağıdaki curl komutunu verin:
curl http://localhost:8081/games/create \
--include \
--header "Content-Type: application/json" \
--request "POST"
Komut çıkışı:
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"
Oyunu kapat
curl http://localhost:8081/games/close \
--include \
--header "Content-Type: application/json" \
--data '{"gameUUID": "f45b0f7f-405b-4e67-a3b8-a624e990285d"}' \
--request "PUT"
Komut çıkışı:
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"
Özet
Bu adımda, maç eşleştirme hizmetini dağıtarak oyun oluşturma ve oyuncuları bu oyuna atama işlemlerini gerçekleştirdiniz. Bu hizmet, rastgele bir kazanan seçip tüm oyuncuların games_played (oynanan oyunlar) ve games_won (kazanılan oyunlar) istatistiklerini güncelleyerek oyunu kapatma işlemini de gerçekleştirir.
Sonraki Adımlar
Hizmetleriniz çalışır duruma geldiğine göre artık oyuncuların kaydolup oyun oynamaya başlamasının zamanı geldi.
6. Oynatmaya başlama
Profil ve eşleştirme hizmetleri çalışır duruma geldiğine göre, sağlanan Locust oluşturucularını kullanarak yük oluşturabilirsiniz.
Locust, jeneratörleri çalıştırmak için bir web arayüzü sunar ancak bu laboratuvarda komut satırını (–headless seçeneği) kullanacaksınız.
Oyuncuları kaydetme
Öncelikle oyuncu oluşturmanız gerekir.
./generators/authentication_server.py dosyasında oyuncu oluşturmak için kullanılan Python kodu şu şekilde görünür:
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'")
Oyuncu adları, e-posta adresleri ve şifreleri rastgele oluşturulur.
Başarıyla kaydolan oyuncular, okuma yükü oluşturmak için ikinci bir görev tarafından alınır.
@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]")
Aşağıdaki komut, 30 saniye boyunca (t=30s) aynı anda iki iş parçacığı (u=2) eşzamanlılığıyla yeni oyuncular oluşturacak ./generators/authentication_server.py dosyasını çağırır:
cd ~/spanner-gaming-sample
locust -H http://127.0.0.1:8080 -f ./generators/authentication_server.py --headless -u=2 -r=2 -t=30s
Oyuncular oyunlara katılır
Kayıtlı oyuncularınız olduğuna göre artık oyun oynamaya başlayabilirler.
./generators/match_server.py dosyasında oyun oluşturmak ve kapatmak için kullanılan Python kodu şu şekilde görünür:
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)
Bu oluşturucu çalıştırıldığında oyunları 2:1 oranında (açma:kapatma) açıp kapatır. Bu komut, jeneratörü 10 saniye boyunca çalıştırır (-t=10s):
locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s
Özet
Bu adımda, oyuncuların oyun oynamak için kaydolmasını simüle ettiniz ve ardından oyuncuların eşleştirme hizmetini kullanarak oyun oynaması için simülasyonlar çalıştırdınız. Bu simülasyonlarda, hizmetlerimizin REST API'sine istek göndermek için Locust Python çerçevesi kullanıldı.
Oyuncu oluşturma ve oyun oynama süresini, eşzamanlı kullanıcı sayısını (-u) istediğiniz gibi değiştirebilirsiniz.
Sonraki Adımlar
Simülasyondan sonra Spanner'ı sorgulayarak çeşitli istatistikleri kontrol etmek isteyeceksiniz.
7. Oyun istatistiklerini alma
Oyuncuların kaydolup oyun oynayabildiği simülasyonu yaptığımıza göre artık istatistiklerinizi kontrol edebilirsiniz.
Bunu yapmak için Cloud Console'u kullanarak Spanner'a sorgu istekleri gönderin.

Açık ve kapalı oyunları kontrol etme
Kapanmış bir oyunda finished zaman damgası doldurulmuşken açık bir oyunda finished değeri NULL olur. Bu değer, oyun kapatıldığında ayarlanır.
Bu sorgu, kaç oyunun açık ve kaç oyunun kapalı olduğunu kontrol etmenize olanak tanır:
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
)
Sonuç:
|
|
|
|
|
|
Oynayan ve oynamayan oyuncu sayısını kontrol etme
current_game sütunu ayarlanmışsa oyuncu oyun oynuyor demektir. Aksi takdirde, kullanıcılar şu anda oyun oynamıyor demektir.
Şu anda kaç oyuncunun oynadığını ve kaç oyuncunun oynamadığını karşılaştırmak için şu sorguyu kullanın:
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
)
Sonuç:
|
|
|
|
|
|
En iyi kazananları belirleme
Bir oyun kapatıldığında, oyunculardan biri rastgele seçilerek kazanan ilan edilir. Oyunu kapatırken bu oyuncunun games_won istatistiği artırılır.
SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;
Sonuç:
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} |
Özet
Bu adımda, Spanner'ı sorgulamak için Cloud Console'u kullanarak oyuncular ve oyunlarla ilgili çeşitli istatistikleri incelediniz.
Sonraki Adımlar
Şimdi de temizleme işlemini yapın.
8. Temizleme (isteğe bağlı)
Temizlemek için Cloud Console'un Cloud Spanner bölümüne gidip "Cloud Spanner örneği oluşturma" adlı codelab adımında oluşturduğumuz "cloudspanner-gaming" örneğini silmeniz yeterlidir.
9. Tebrikler!
Tebrikler, Spanner'da örnek bir oyunu başarıyla dağıttınız
Sırada ne var?
Bu laboratuvarda, golang sürücüsünü kullanarak Spanner ile çalışma konusundaki çeşitli konular hakkında bilgi edindiniz. Bu sayede aşağıdaki gibi önemli kavramları daha iyi anlayabilirsiniz:
- Şema tasarımı
- DML ve Mutasyonlar
- Go ile çalışma
Spanner'ı oyununuzun arka ucu olarak kullanma konusunda başka bir örnek için Cloud Spanner Game Trading Post adlı codelab'e göz atmayı unutmayın.