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 semantiği 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şeli olan oyunların mimarisine mükemmel bir uyum sağlar

Bu laboratuvarda, oyuncuların kaydolup oynamaya başlamaları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, kaydolup oyunu oynayan oyuncuları simüle etmek için Python yük çerçevesi Locust.io'dan faydalanarak veri oluşturacaksınız. Ardından, kaç oyuncunun oynadığını belirlemek ve oyuncuların oyuna dair bazı istatistikleri belirlemek için Spanner'ı sorgularsınız oynanan maçlar ve oynanan maçlar.

Son olarak, bu laboratuvarda oluşturulan kaynakları temizleyeceksiniz.

Neler oluşturacaksınız?

Bu laboratuvar kapsamında:

  • Spanner örneği oluşturma
  • Herkese açık kullanıcı kaydına git alanında yazılmış bir Profil hizmetini dağıtın
  • Oyuncuları oyunlara atamak, kazananları belirlemek ve oyuncuların bilgilerini güncellemek için Go'da yazılan bir Eşleştirme hizmeti dağıtın Oyun istatistikleri.

Neler öğreneceksiniz?

  • Cloud Spanner örneği oluşturma
  • Oyun veritabanı ve şeması nasıl oluşturulur?
  • Cloud Spanner ile çalışacak Go uygulamaları dağıtma
  • Locust kullanarak veri oluşturma
  • Oyunlar ve oyuncularla ilgili soruları yanıtlamak için Cloud Spanner'da veri sorgulama.

İhtiyacınız olanlar

  • Faturalandırma hesabına bağlı bir 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 bir hesap oluşturmanız gerekir. Google Cloud Platform konsolunda ( console.cloud.google.com) oturum açın ve yeni bir proje oluşturun.

Zaten bir projeniz varsa konsolun sol üst köşesindeki proje seçimi açılan menüsünü tıklayın:

6c9406d9b014760.png

Sonra ‘YENİ PROJE’yi tıklayın. düğmesini tıklayın:

949d83c8a4ee17d9.png

Henüz projeniz yoksa ilk projenizi oluşturmak için şuna benzer bir iletişim kutusu görmeniz gerekir:

870a3cbd6541ee86.png

Sonraki proje oluşturma iletişim kutusu yeni projenizin ayrıntılarını girmenize olanak tanır:

6a92c57d3250a4b3.png

Tüm Google Cloud projeleri için benzersiz bir ad olan proje kimliğini unutmayın (yukarıdaki ad daha önce alınmış ve size uygun olmayacaktır!). Bu kod, bu codelab'in ilerleyen bölümlerinde PROJECT_ID olarak adlandırılacaktır.

Ardından, henüz yapmadıysanız Developers Console'da faturalandırmayı etkinleştirmeniz ve Google Cloud kaynaklarını kullanmanız ve Cloud Spanner API'yi etkinleştirmeniz gerekir.

15d0ef27a8fbab27.png

Bu codelab'i çalıştırmanın maliyeti birkaç dolardan fazla değildir. Ancak daha fazla kaynak kullanmaya karar verirseniz veya bu kaynakları çalışır durumda bırakırsanız daha yüksek ücret ödemeniz gerekebilir (bu belgenin sonundaki "temizlik" bölümüne bakın). Google Cloud Spanner fiyatlandırması burada açıklanmıştır.

Yeni Google Cloud Platform kullanıcıları, bu codelab'i tamamen ücretsiz hale getirecek 300 ABD doları değerindeki ücretsiz denemeden yararlanabilir.

Google Cloud Shell Kurulumu

Google Cloud ve Spanner, dizüstü bilgisayarınızdan uzaktan çalıştırılabilse de bu codelab'de, Cloud'da çalışan bir komut satırı ortamı olan Google Cloud Shell'i kullanacağız.

Bu Debian tabanlı sanal makine, ihtiyacınız olan tüm geliştirme araçlarıyla yüklüdür. 5 GB boyutunda kalıcı bir ana dizin sunar ve Google Cloud'da çalışarak ağ performansını ve kimlik doğrulamasını büyük ölçüde iyileştirir. Yani bu codelab'de ihtiyacınız olan tek şey bir tarayıcıdır (Evet, Chromebook'ta çalışır).

  1. Cloud Console'dan Cloud Shell'i etkinleştirmek için Cloud Shell'i Etkinleştir'i gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A tıklamanız yeterlidir (sağlanması ve ortama bağlanması birkaç dakika 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ğrulanmış olduğunu ve projenin PROJECT_ID'nize ayarlanmış olduğunu göreceksiniz.

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 vermeniz yeterlidir:

gcloud config set project <PROJECT_ID>

PROJECT_ID'nizi mi arıyorsunuz? Kurulum adımlarında kullandığınız kimliği kontrol edin veya Cloud Console kontrol panelinden arayın:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell bazı ortam değişkenlerini de varsayılan olarak ayarlar. Bu değişkenler, gelecekte komut çalıştırdığınızda işinize yarayabilir.

echo $GOOGLE_CLOUD_PROJECT

Komut çıkışı

<PROJECT_ID>

Kodu indirme

Bu laboratuvarın kodunu Cloud Shell'den indirebilirsiniz. Bu, v0.1.0 sürümüne dayalı olduğu için 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şturma aracını kurun

Locust, REST API uç noktalarını test etmede kullanışlı bir Python yük testi çerçevesidir. Bu codelab'de "üretkenler"de 2 farklı yük testi var :

  • authentication_server.py: Oyuncular oluşturma ve tek noktalı aramaları taklit edecek rastgele bir oyuncu edinmeyle ilgili görevleri içerir.
  • match_server.py: Oyun oluşturma ve oyunları kapatmayla ilgili görevleri içerir. Oyun oluşturulduğunda şu anda oyun oynamayan 100 rastgele oyuncu atanır. Maçlar kapatıldığında games_played ve games_won istatistikleri güncellenir ve bu oyuncuların gelecekteki bir oyuna atanmasına izin verilir.

Locust'u Cloud Shell'de çalıştırmak için Python 3.7 veya üzeri gerekir. Cloud Shell'de Python 3.9 yüklü olduğu için sürümü doğrulamaktan başka bir şey yapmanız gerekmez:

python -V

Komut çıkışı

Python 3.9.12

Artık Locust gereksinimlerini 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

Yeni yüklenen locust ikili programının bulunabilmesi için PATH yolunu güncelleyin:

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

Komut çıkışı

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

Özet

Bu adımda, henüz oluşturmadıysanız projenizi oluşturdunuz, Cloud Shell'i etkinleştirdiniz ve bu laboratuvarın kodunu indirdiniz.

Son olarak, laboratuvarın ilerleyen bölümlerinde yükleme oluşturma için Locust'u ayarladınız.

Sıradaki

Şimdi, Cloud Spanner örneğini ve veritabanını kuracaksınız.

3. Spanner örneği ve veritabanı oluşturma

Spanner örneğini oluşturma

Bu adımda codelab için Spanner örneğimizi oluşturduk. Soldaki Hamburger Menüsünde 1a6580bd3d3e6783.png Spanner girişini 3129589f7bc9e5ce.png veya "/" tuşuna basarak Spanner girişini arayın ve "Spanner" yazın.

36e52f8df8e13b99.png

Daha sonra, 95269e75bc8c3e4d.png öğesini tıklayın ve örneğiniz için cloudspanner-gaming örnek adını girip 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 öğesine ihtiyacımız var.

Son olarak, "Oluştur"u tıklayın. ve birkaç saniye içinde Cloud Spanner örneğiniz olur.

4457c324c94f93e6.png

Veritabanı ve şemayı oluşturma

Örneğiniz çalışmaya başladıktan sonra veritabanını oluşturabilirsiniz. Spanner, tek bir örnek üzerinde birden çok veritabanı kullanılmasına olanak sağlar.

Veritabanı, şemanızı tanımladığınız yerdir. 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 öncüyü de yapılandırabilirsiniz. Spanner'daki veritabanları hakkında daha fazla bilgi edinin.

Bu code-lab'de, veritabanını varsayılan seçeneklerle oluşturacak ve şemayı oluşturma sırasında sağlayacaksınız.

Bu laboratuvarda iki tablo oluşturulur: oyuncular ve oyunlar.

77651ac12e47fe2a.png

Oyuncular zaman içinde çok sayıda oyuna katılabilir, ancak tek seferde yalnızca bir oyuna katılabilirler. Oyuncular, games_played ve games_won gibi ilgi çekici istatistikleri takip etmek için JSON veri türü olarak stats'ı da kullanır. Daha sonra başka istatistikler de eklenebileceğinden, bu oyuncular için etkili bir şekilde şemasız bir sütundur.

Oyunlar, Spanner'ın ARRAY veri türünü kullanarak katılan oyuncuları takip eder. Oyun kapatılana kadar, oyunun kazananı ve tamamlanmış özellikleri doldurulmaz.

Oyuncunun current_game oyununun geçerli bir oyun olduğundan emin olmak için bir tane yabancı anahtar vardır.

Şimdi "Veritabanı Oluştur"u tıklayarak veritabanını oluşturun inceleyebilirsiniz:

a820db6c4a4d6f2d.png

Sonra da gerekli bilgileri girin. En önemli seçenekler veritabanı adı ve diyalekttir. Bu örnekte veritabanını sample-game olarak adlandırdık ve Google Standart SQL diyalektini 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ı şu şekilde görünmelidir:

d39d358dc7d32939.png

Şimdi, kod laboratuvarının ilerleyen bölümlerinde kullanılmak üzere Cloud Shell'de bazı ortam değişkenlerini ayarlamanız gerekiyor. Bu nedenle ornek-kimligini not edin ve INSTANCE_ID ile DATABASE_ID değerlerini 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 oyunda kullanılan şemayı da tanımladınız.

Sıradaki

Daha sonra, oyuncuların oyunu oynamak için kaydolabilmeleri için profil hizmetini dağıtacaksınız.

4. Profil hizmetini dağıtma

Hizmete genel bakış

Profil hizmeti, Go'da yazılmış ve gin çerçevesinden yararlanan bir REST API'dir.

4fce45ee6c858b3e.png

Bu API'de oyuncular oyun oynamak için kaydolabilir. Bu komut, oynatıcı adını, e-posta adresini ve şifresini kabul eden basit bir POST komutuyla oluşturulur. Şifre bcrypt ile şifrelenir ve karma veritabanında depolanır.

E-posta, benzersiz bir tanımlayıcı olarak değerlendirilirken player_name, oyunun görüntülenmesi amacıyla kullanılır.

Bu API şu anda giriş işlemini desteklemiyor. Ancak bunu ek bir alıştırma olarak uygulayabilirsiniz.

Profil hizmetinin ./src/golang/profile-service/main.go dosyası, iki birincil uç noktayı aşağıdaki gibi gösterir:

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 ise 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 şeylerden biri Spanner bağlantısını ayarlamak. Bu işlem, hizmet için oturum havuzunu oluşturmak amacıyla 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 şekilde tanımlanan struct'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"`
}

Oynatıcı ekleme işlevi, oynatıcı ekleme işlemi toplu eklemelerden ziyade tek bir ifade olduğundan ReadWrite işlemi içine bir DML ekinden yararlanır. İşlev aşağıdaki gibi 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
}

Bir oyuncuyu UUID'sine göre almak için basit bir okuma verilir. Bu, oynatıcıya ait playerUUID, Player_name,email ve stats bilgilerini getirir.

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öreceksiniz.

Bu bilgiler doğrultusunda hizmeti çalıştırma zamanı gelmiş demektir.

Profil hizmetini çalıştırma

go komutunu kullanarak hizmeti ç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 göndererek 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 oyununuzu oynamak için kaydolmalarına olanak tanıyan profil hizmetini dağıttınız ve yeni bir oyuncu oluşturmak üzere bir POST api çağrısı göndererek hizmeti test ettiniz.

Sonraki Adımlar

Bir sonraki adımda eşleştirme hizmetini dağıtacaksınız.

5. Eşleştirme hizmetini dağıtma

Hizmete genel bakış

Eşleştirme hizmeti, Go'da yazılmış ve cin çerçevesinden yararlanan 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 o oyuna atanır.

Bir oyun kapandığında, bir kazanan rastgele seçilir ve her oyuncunun games_played ve games_won istatistikleri düzenlenir. Ayrıca, her oyuncu artık oynamadığını belirtecek şekilde güncellenir ve böylece gelecekteki oyunları oynayabilir.

Eşleştirme hizmetine ait ./src/golang/matchmaking-service/main.go dosyası, profile hizmetiyle benzer bir kuruluma ve koda sahip olduğundan burada tekrarlanmaz. Bu hizmet, aşağıdaki gibi iki birincil uç noktayı 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 hem Oyun yapısı hem de daha daraltılmış Player ve PlayerStats yapılarını 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 oyuncu arasından rastgele bir seçim yapar.

Büyük değişikliklerde mutasyonlar DML'den daha yüksek performanslı olduğundan, oyunu oluşturmak ve oyuncuları atamak için Spanner mutasyonları 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
}

Oynatıcılar, GoogleSQL'in TABLESPACE RESERVOIR özelliği kullanılarak SQL ile rastgele seçilir.

Bir oyunu kapatmak biraz daha karmaşık bir işlemdir. Bu süreç, oyunun oyuncuları arasından rastgele bir kazananın seçilmesini, oyunun bittiği anın işaretlenmesini ve her oyuncunun güncellenmesini içerir. games_played ve games_won istatistiklerine sahip.

Bu karmaşıklık ve değişiklik 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, hizmetin ./src/golang/matchmaking-service/config/config.go bölümünde açıklandığı üzere tekrar ortam değişkenleri aracılığıyla işlenir.

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

Profil hizmetiyle çakışma olmaması için bu hizmet varsayılan olarak localhost:8081 üzerinde çalışır.

Bu bilgiler ışığında şimdi sıra eşleştirme hizmetini çalıştırmaya geldi.

Eşleştirme hizmetini çalıştırma

go komutunu kullanarak hizmeti çalıştırın. Bu şekilde hizmet, 8082 numaralı bağlantı noktasında çalışacak. Bu hizmet, profil-hizmet ile aynı bağımlılıklara 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ştur

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, oyun oluşturma ve bu oyuna oyuncu atama işlemlerini yürütmek için eşleştirme hizmetini dağıttınız. Bu hizmet ayrıca rastgele bir kazanan seçip oyundaki tüm oyuncuların oyunlarını güncelleyen bir oyunu kapatma işini de yapar. games_played ve games_won istatistiklerine sahip.

Sonraki Adımlar

Hizmetleriniz çalıştığına göre, şimdi oyuncuları kaydolup oyun oynamaya teşvik etmenin zamanı geldi.

6. Oynatmaya başlama

Profil ve eşleştirme hizmetleri çalıştığına göre, sağlanan yer işareti oluşturma araçlarını kullanarak yük oluşturabilirsiniz.

Locust, oluşturucuları çalıştırmak için bir web arayüzü sunar. Ancak bu laboratuvarda, komut satırını (–gözetimsiz seçeneği) kullanacaksınız.

Oyuncuları kaydedin

İlk olarak oyuncu oluşturmanız gerekir.

./generators/authentication_server.py dosyasında oynatıcı oluşturmak için kullanılan python kodu aşağıdaki gibi 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 şifreler rastgele oluşturulur.

Başarılı bir şekilde kaydolan oyuncular, okuma yükü oluşturmak için ikinci bir görevle 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, aynı anda iki iş parçacığının eşzamanlılık (u=2) ile 30 saniye (t=30 sn) süreyle 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

Kaydolan oyuncular artık oyun oynamaya başlamak istiyorlar.

./generators/match_server.py dosyasında oyun oluşturmak ve kapatmak için kullanılan python kodu aşağıdaki gibi 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şturma aracı çalıştırıldığında oyunları 2:1 oranında (open:close) açıp kapatır. Bu komut, oluşturma aracını 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, oyun oynamaya kaydolan oyuncuları simüle ettiniz ve ardından oyuncuların eşleştirme hizmetini kullanarak oyun oynamaları için simülasyonlar yaptınız. Bu simülasyonlar, hizmetlerimize talep göndermek için Locust Python çerçevesinden REST API

Oyuncu oluşturmak ve oyun oynamak için harcanan süreyi ve 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 isteyebilirsiniz.

7. Oyun istatistiklerini alma

Oyuncuların kaydolup oyun oynayabildiğini simüle ettiğimize göre artık istatistiklerinizi kontrol etmelisiniz.

Bunu yapmak için Cloud Console'u kullanarak Spanner'a sorgu istekleri gönderin.

b5e3154c6f7cb0cf.png

Açık ve kapalı maçları kontrol etme

Kapalı oyun, bitti zaman damgası doldurulmuşken bitti zaman damgası NULL olarak doldurulur. Bu değer, oyun kapatıldığında ayarlanır.

Bu sorguyla yalnızca kaç oyunun açık, kaç tanesinin kapalı olduğunu kontrol edebilirsiniz:

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

Bir oyuncu, current_game sütunu ayarlanmışsa oyun oynamaktadır. Aksi takdirde, o anda oyun oynamazlar.

O nedenle, o anda oynayan ve oynamayan oyuncu sayısı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

Maç kapatıldığında, oyunculardan biri rastgele kazanan olarak seçilir. Söz konusu oyuncunun games_won istatistiği oyun kapanışında artar.

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

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

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

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

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

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

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

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

15513852-3f2a-494f-b437-fe7125d15f1b

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

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

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

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

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

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

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

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

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

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

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

Özet

Bu adımda, Spanner'ı sorgulamak için Cloud Console'u kullanarak oyuncular ve oyunlarla ilgili çeşitli istatistiklerini incelediniz.

Sonraki Adımlar

Şimdi de sıra temizlik yapmaya geldi.

8. Temizleniyor (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 başarıyla örnek bir oyun dağıttınız

Sırada ne var?

Bu laboratuvarda, golang sürücüsünü kullanarak Spanner ile çalışmayla ilgili çeşitli konulara giriş yaptınız. Bu inceleme, aşağıdakiler gibi kritik kavramları anlamanız için size daha iyi bir temel sağlayacaktır:

  • Şema tasarımı
  • DML ve Mutasyonlar
  • Golang ile çalışma

Oyununuzun arka ucu olarak Spanner'la çalışmayla ilgili başka bir örnek için Cloud Spanner Oyun Ticareti codelab'ine göz atmayı unutmayın.