Cloud Spanner Oyun Geliştirmeye Başlama

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.

413fdd57bb0b68bc.png

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

  • Bir faturalandırma hesabına bağlı olan Google Cloud projesi.
  • Chrome veya Firefox gibi bir web tarayıcısı

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:

6c9406d9b014760.png

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

949d83c8a4ee17d9.png

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

870a3cbd6541ee86.png

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

6a92c57d3250a4b3.png

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.

15d0ef27a8fbab27.png

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

  1. Cloud Shell'i Cloud Console'dan etkinleştirmek için Cloud Shell'i Etkinleştir'i gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A tıklamanız yeterlidir (ortamın sağlanması ve bağlantının kurulması yalnızca birkaç saniye sürer).

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

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

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:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

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 1a6580bd3d3e6783.pngSpanner girişini arayın 3129589f7bc9e5ce.png veya "/" tuşuna basıp "Spanner" yazarak Spanner'ı arayın.

36e52f8df8e13b99.png

Ardından 95269e75bc8c3e4d.png 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.

4457c324c94f93e6.png

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.

77651ac12e47fe2a.png

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:

a820db6c4a4d6f2d.png

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:

d39d358dc7d32939.png

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

f6f98848d3aea9c.png

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.

4fce45ee6c858b3e.png

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.

9aecd571df0dcd7c.png

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:

90eceac76a6bb90b.png

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.

b5e3154c6f7cb0cf.png

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ç:

Type

NumGames

Open Games

0

Closed Games

175

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ç:

Type

NumPlayers

Playing

0

Not Playing

310

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.