Cloud Spanner ゲーム開発の開始

1. はじめに

Cloud Spanner は、水平スケーリングが可能なフルマネージドでグローバルに分散されたリレーショナル データベース サービスで、パフォーマンスと高可用性を犠牲にすることなく ACID トランザクションと SQL セマンティクスを提供します。

このような機能により、Spanner はグローバルなプレーヤー ベースを実現したいと考えているゲームのアーキテクチャや、データの整合性が問題となるゲームのアーキテクチャに非常に適しています。

このラボでは、リージョンの Spanner データベースとやり取りする 2 つの Go サービスを作成し、プレーヤーが登録してゲームを開始できるようにします。

413fdd57bb0b68bc.png

次に、Python のロード フレームワーク Locust.io を使用してデータを生成し、プレイヤーがゲームに登録してプレイすることをシミュレートします。次に、Spanner に対してクエリを実行し、プレイしているプレーヤー数と、各プレーヤーのプレイ状況に関する統計情報を取得します。勝った試合数とプレイされた試合数を比較できます。

最後に、このラボで作成したリソースをクリーンアップします。

作成するアプリの概要

このラボでは、次の作業を行います。

  • Spanner インスタンスの作成
  • Go で記述されたプロフィール サービスをデプロイしてプレーヤーの登録を処理する
  • Go で記述されたマッチメイキング サービスをデプロイして、プレーヤーをゲームに割り当て、勝者を決定し、プレーヤーの ID を更新します。ゲームの統計情報です。

学習内容

  • Cloud Spanner インスタンスを設定する方法
  • ゲームのデータベースとスキーマを作成する方法
  • Cloud Spanner と連携する Go アプリをデプロイする方法
  • Locust を使用してデータを生成する方法
  • Cloud Spanner でデータをクエリして、ゲームやプレーヤーに関する質問に回答する方法。

必要なもの

  • 請求先アカウントに接続されている Google Cloud プロジェクト。
  • ウェブブラウザ(ChromeFirefox など)

2. 設定と要件

プロジェクトを作成する

Google アカウント(Gmail または Google Apps)をお持ちでない場合は、1 つ作成する必要があります。Google Cloud Platform コンソール(console.cloud.google.com)にログインし、新しいプロジェクトを作成します。

すでにプロジェクトが存在する場合は、コンソールの左上にあるプロジェクト選択プルダウン メニューをクリックします。

6c9406d9b014760.png

[新しいプロジェクト] をクリックします。] ボタンをクリックし、新しいプロジェクトを作成します。

949d83c8a4ee17d9.png

まだプロジェクトが存在しない場合は、次のような最初のプロジェクトを作成するためのダイアログが表示されます。

870a3cbd6541ee86.png

続いて表示されるプロジェクト作成ダイアログでは、新しいプロジェクトの詳細を入力できます。

6a92c57d3250a4b3.png

プロジェクト ID を忘れないようにしてください。プロジェクト ID は、すべての Google Cloud プロジェクトを通じて一意の名前にする必要があります(上記の名前はすでに使用されているため使用できません)。以降、この Codelab では PROJECT_ID と呼びます。

次に、Google Cloud リソースを使用し、Cloud Spanner API を有効にするために、Developers Console で課金を有効にする必要があります。

15d0ef27a8fbab27.png

この Codelab をすべて実行しても費用はかかりませんが、より多くのリソースを使用する場合や実行したままにする場合は、コストが高くなる可能性があります(このドキュメントの最後にある「クリーンアップ」セクションをご覧ください)。Google Cloud Spanner の料金については、こちらをご覧ください。

Google Cloud Platform の新規ユーザーの皆さんには、$300 の無料トライアルをご利用いただけます。その場合は、この Codelab を完全に無料でご利用いただけます。

Google Cloud Shell の設定

Google Cloud と Spanner はノートパソコンからリモートで操作でき、この Codelab では、Google Cloud Shell(Cloud 上で動作するコマンドライン環境)を使用します。

この Debian ベースの仮想マシンには、必要な開発ツールがすべて揃っています。永続的なホーム ディレクトリが 5 GB 用意されており、Google Cloud で稼働するため、ネットワークのパフォーマンスと認証が大幅に向上しています。つまり、この Codelab に必要なのはブラウザだけです(はい、Chromebook で動作します)。

  1. Cloud コンソールから Cloud Shell を有効にするには、「Cloud Shell をアクティブにする」アイコン gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A をクリックします(環境のプロビジョニングと接続には少し時間がかかります)。

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

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

Cloud Shell に接続すると、すでに認証が完了しており、プロジェクトに各自の PROJECT_ID が設定されていることがわかります。

gcloud auth list

コマンド出力

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

コマンド出力

[core]
project = <PROJECT_ID>

なんらかの理由でプロジェクトが設定されていない場合は、次のコマンドを実行します。

gcloud config set project <PROJECT_ID>

PROJECT_ID をお探しですか?設定手順で使用した ID を確認するか、Cloud コンソール ダッシュボードで調べます。

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell では、デフォルトで環境変数もいくつか設定されます。これらの変数は、以降のコマンドを実行する際に有用なものです。

echo $GOOGLE_CLOUD_PROJECT

コマンド出力

<PROJECT_ID>

コードをダウンロードする

Cloud Shell で、このラボのコードをダウンロードできます。これは v0.1.0 リリースに基づくため、このタグを確認してください。

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

コマンド出力

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

Locust 負荷生成ツールを設定する

Locust は Python の負荷テスト フレームワークで、REST API エンドポイントのテストに役立ちます。この Codelab の「ジェネレータ」には、2 つの異なる負荷テストがあります。ディレクトリ内にあります。

  • authentication_server.py: プレーヤーを作成し、ランダムなプレーヤーで単一ポイント ルックアップを模倣するタスクが含まれています。
  • match_server.py: ゲームの作成やゲームの終了などのタスクが格納されています。ゲームを作成すると、現在ゲームをプレイしていないプレーヤーを 100 人ランダムに割り当てます。ゲームを閉じると、games_played と game_won の統計情報が更新され、これらのプレーヤーを今後のゲームに割り当てることができます。

Cloud Shell で Locust を実行するには、Python 3.7 以降が必要です。Cloud Shell には Python 3.9 が付属しているため、バージョンを確認するだけで済みます。

python -V

コマンド出力

Python 3.9.12

これで、Locust の要件をインストールできるようになりました。

pip3 install -r requirements.txt

コマンド出力

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

次に、新しくインストールした locust バイナリが見つかるように PATH を更新します。

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

コマンド出力

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

概要

このステップでは、プロジェクトを設定し(まだ行っていない場合)、Cloud Shell をアクティブにして、このラボ用のコードをダウンロードしました。

最後に、ラボの後半で負荷を生成するために Locust を設定します。

次のステップ

次に、Cloud Spanner のインスタンスとデータベースを設定します。

3. Spanner のインスタンスとデータベースを作成する

Spanner インスタンスを作成する

このステップでは、この Codelab 用の Spanner インスタンスを設定します。左上のハンバーガー メニュー 3129589f7bc9e5ce.png で Spanner の項目 1a6580bd3d3e6783.png を探すか、「/」を押して「Spanner」と入力し Spanner を検索します。

36e52f8df8e13b99.png

次に、[95269e75bc8c3e4d.png] をクリックし、インスタンスのインスタンス名「cloudspanner-gaming」を入力し、構成(us-central1 などのリージョン インスタンスを選択)を選択してフォームに入力し、ノード数を設定します。この Codelab で必要なのは 500 processing units のみです。

最後に、重要な [作成] をクリックすると、数秒で指定した Cloud Spanner インスタンスが作成されます。

4457c324c94f93e6.png

データベースとスキーマを作成する

インスタンスを実行したら、データベースを作成できます。Spanner では、1 つのインスタンスで複数のデータベースを使用できます。

データベースはスキーマを定義する場所です。また、データベースにアクセスできるユーザーの制御、カスタム暗号化の設定、オプティマイザーの構成、保持期間の設定を行うこともできます。

マルチリージョン インスタンスでは、デフォルトのリーダーも構成できます。Spanner 上のデータベースの詳細については、こちらをご覧ください。

この Codelab では、デフォルトのオプションを使用してデータベースを作成し、作成時にスキーマを指定します。

このラボでは、playersgames という 2 つのテーブルを作成します。

77651ac12e47fe2a.png

プレーヤーは、長期にわたって多くのゲームに参加できますが、一度に参加できるゲームは 1 つのみです。プレーヤーには JSON データ型stats も用意されており、games_playedgames_won などの興味深い統計情報を追跡できます。他の統計情報は後で追加される可能性があるため、これは実質的にプレーヤー向けのスキーマレスの列です。

ゲームは、Spanner の ARRAY データ型を使用して参加したプレーヤーを追跡します。ゲームの勝者属性と終了属性は、ゲームが終了するまで入力されません。

プレーヤーの current_game が有効なゲームであることを確認するための外部キーは 1 つあります。

[データベースを作成] をクリックしてデータベースを作成します。[インスタンスの概要]:

a820db6c4a4d6f2d.png

詳細を入力します。重要なオプションは、データベース名と言語です。この例では、データベースに sample-game という名前を付けて、Google 標準 SQL 言語を選択しました。

[スキーマ] で、この DDL をコピーしてボックスに貼り付けます。

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

次に、[作成] ボタンをクリックし、データベースが作成されるまで数秒待ちます。

データベースの作成ページは次のようになります。

d39d358dc7d32939.png

この Codelab で後ほど使用するために、Cloud Shell でいくつかの環境変数を設定する必要があります。instance-id をメモして、INSTANCE_ID と DATABASE_ID を Cloud Shell で設定します。

f6f98848d3aea9c.png

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

概要

このステップでは、Spanner インスタンスと sample-game データベースを作成しました。このサンプルゲームで使用するスキーマも定義しました。

次のステップ

次に、プロファイル サービスをデプロイして、プレーヤーが登録してゲームをプレイできるようにします。

4. プロファイル サービスをデプロイする

サービスの概要

プロファイル サービスは、Gin フレームワークを活用する Go で記述された REST API です。

4fce45ee6c858b3e.png

この API では、プレーヤーは登録してゲームをプレイできます。これは、プレーヤー名、メールアドレス、パスワードを受け取る単純な POST コマンドによって作成されます。パスワードは bcrypt で暗号化され、ハッシュはデータベースに保存されます。

Email は一意の識別子として扱われ、player_name はゲームの表示のために使用されます。

この API は現在ログインを処理しませんが、この実装は追加の演習として残すことができます。

プロファイル サービスの ./src/golang/profile-service/main.go ファイルは、次の 2 つの主要エンドポイントを公開します。

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

これらのエンドポイントのコードは、Player モデルにルーティングされます。

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

   ctx, client := getSpannerConnection(c)

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

   c.IndentedJSON(http.StatusOK, player)
}

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

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

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

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

サービスが最初に行う処理の 1 つは、Spanner 接続の設定です。これは、サービスのセッション プールを作成するためにサービスレベルで実装されます。

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

PlayerPlayerStats は、次のように定義される構造体です。

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

プレーヤーを追加する関数は、ReadWrite トランザクション内で DML 挿入を利用します。これは、プレーヤーの追加はバッチ挿入ではなく単一のステートメントであるためです。この関数は次のようになります。

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 に基づいてプレーヤーを取得するには、単純な読み取りが実行されます。このコードは、プレーヤーの playerUUID、player_name、emailstats を取得します。

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
}

デフォルトでは、サービスは環境変数を使用して構成されます。./src/golang/profile-service/config/config.go ファイルの関連するセクションをご覧ください。

func NewConfig() (Config, error) {
   *snip*
   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8080)

   // Bind environment variable override
   viper.BindEnv("server.host", "SERVICE_HOST")
   viper.BindEnv("server.port", "SERVICE_PORT")
   viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
   viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
   viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")

   *snip*

   return c, nil
}

デフォルトの動作では、localhost:8080 でサービスが実行されることがわかります。

この情報をもとに、サービスを実行します。

プロファイル サービスを実行する

go コマンドを使用してサービスを実行します。これにより依存関係がダウンロードされ、ポート 8080 でサービスが確立されます。

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

コマンド出力:

[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

curl コマンドを実行してサービスをテストします。

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

コマンド出力:

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"

概要

このステップでは、プレーヤーが登録してゲームをプレイできるようにするプロファイル サービスをデプロイし、POST API 呼び出しを発行して新しいプレーヤーを作成し、サービスをテストしました。

次のステップ

次のステップでは、マッチメイキング サービスをデプロイします。

5. マッチメイキング サービスをデプロイする

サービスの概要

マッチメイキング サービスは、gin フレームワークを活用する Go で記述された REST API です。

9aecd571df0dcd7c.png

この API では、ゲームが作成され、クローズされます。ゲームが作成されると、現在ゲームをプレイしていない 10 人のプレーヤーがゲームに割り当てられます。

ゲームが終了すると、勝者がランダムに選ばれ、各プレーヤーのgames_playedgames_won の統計データが調整されます。また、各プレーヤーは更新され、プレイを終了したことが通知され、それ以降のゲームをプレイできるようになったことも示されます。

マッチメイキング サービスの ./src/golang/matchmaking-service/main.go ファイルは、./src/golang/matchmaking-service/main.go サービスと同様の設定とコードに従うため、ここでは繰り返しません。このサービスは、次の 2 つの主要なエンドポイントを公開します。

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

このサービスは、Game 構造体と、スリム化された Player 構造体と PlayerStats 構造体を提供します。

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

ゲームを作成するため、マッチメイキング サービスは、現在ゲームをプレイしていない 100 人のプレーヤーをランダムに選択します。

ゲームの作成とプレーヤーの割り当てには、Spanner ミューテーションが選択されます。大規模な変更の場合、ミューテーションは DML よりもパフォーマンスが高いためです。

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

プレーヤーのランダム選択は、GoogleSQL の TABLESPACE RESERVOIR 機能を使用して SQL で行われます。

ゲームを終了する方法は少し複雑です。ゲームのプレーヤーから勝者をランダムに選択し、ゲーム終了時刻をマークして、各プレーヤーのスコアを更新するgames_playedgames_won です。

この複雑さと変更量を考慮して、ゲームを終了するために再度ミューテーションが選択されます。

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
}

構成も、サービスの ./src/golang/matchmaking-service/config/config.go に記述されているように、環境変数を介して処理されます。

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

プロファイル サービスとの競合を避けるため、このサービスはデフォルトで localhost:8081 で実行されます。

この情報をもとに、マッチメイキング サービスを実行します。

マッチメイキング サービスを実行する

go コマンドを使用してサービスを実行します。これにより、ポート 8082 で実行されるサービスが確立されます。このサービスにはプロファイル サービスと同じ依存関係が多数あるため、新しい依存関係はダウンロードされません。

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

コマンド出力:

[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

ゲームを作成する

サービスをテストしてゲームを作成します。まず、Cloud Shell で新しいターミナルを開きます。

90eceac76a6bb90b.png

次に、次の curl コマンドを実行します。

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

コマンド出力:

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"

ゲームの終了

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

コマンド出力:

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"

概要

このステップでは、ゲームの作成とそのゲームへのプレーヤーの割り当てを処理するマッチメイキング サービスをデプロイしました。このサービスでは、ゲームの終了も処理します。ゲームはランダムに勝者を選んで、すべてのゲーム プレーヤーの最新のバージョンをアップデートします。games_playedgames_won です。

次のステップ

サービスを稼働させたので、次はユーザーの登録とゲームのプレイを促します。

6. 再生を開始

プロファイルとマッチメイキング サービスが実行されているので、提供されている Locust ジェネレータを使用して負荷を生成できます。

Locust にはジェネレータを実行するためのウェブ インターフェースが用意されていますが、このラボではコマンドライン(–headless オプション)を使用します。

プレーヤーの登録

まず、プレーヤーを生成します。

./generators/authentication_server.py ファイルにプレーヤーを作成する Python コードは次のようになります。

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

プレーヤー名、メールアドレス、パスワードがランダムに生成される。

正常に登録されたプレーヤーは、2 番目のタスクによって取得され、読み取り負荷が生成されます。

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

次のコマンドは、一度に 2 つのスレッド(u=2)の同時実行を使用して 30 秒間(t=30s)の新しいプレーヤーを生成する ./generators/authentication_server.py ファイルを呼び出します。

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

プレーヤーがゲームに参加

登録が完了したプレーヤーは、ゲームのプレイを始めます。

./generators/match_server.py ファイル内にゲームを作成して閉じるための Python コードは次のようになります。

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)

このジェネレータを実行すると、2:1(オープン:クローズ)の比率でゲームの開始と終了が行われます。このコマンドを実行すると、ジェネレータが 10 秒間実行されます(-t=10s)

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

概要

このステップでは、プレーヤーがゲームに登録してプレイし、その後マッチメイキング サービスを使用してプレーヤーがゲームをプレイするためのシミュレーションを実行しました。このシミュレーションでは、Locust Python フレームワークを利用して、当社のサービスにリクエストを発行しました。REST API。

プレーヤーの作成やゲームのプレイにかかった時間や同時ユーザー数(-u))は自由に変更してください。

次のステップ

シミュレーション後、Spanner に対してクエリを実行してさまざまな統計情報を確認します。

7. ゲームの統計情報を取得する

これで、プレーヤーが登録してゲームをプレイできるようになったので、統計情報を確認してみましょう。

これを行うには、Cloud コンソールを使用して Spanner にクエリ リクエストを発行します。

b5e3154c6f7cb0cf.png

オープンゲームとクローズド ゲームの確認

クローズド ゲームとは、finished のタイムスタンプが入力されているゲームで、オープンゲームは finished が NULL になります。この値はゲームが終了したときに設定されます。

したがって、このクエリによって、開いている試合の数と閉じている試合の数をすべて確認できます。

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
)

結果:

Type

NumGames

Open Games

0

Closed Games

175

プレーしているプレーヤーとプレイしていないプレーヤーの量を確認する

current_game 列が設定されている場合、プレーヤーはゲームをプレイしている。それ以外の場合は、現在ゲームをプレイしていません。

そのため、現在プレイしているプレーヤーとプレイしていないプレーヤーの数を比較するには、次のクエリを使用します。

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
)

結果:

Type

NumPlayers

Playing

0

Not Playing

310

勝者を決める

ゲームが終了すると、いずれかのプレーヤーがランダムに選ばれます。そのプレーヤーの games_won 統計情報は、ゲームの終了時に増加します。

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

結果:

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}

概要

このステップでは、Cloud コンソールを使用して Spanner にクエリを実行し、プレーヤーとゲームのさまざまな統計情報を確認しました。

次のステップ

次はクリーンアップです。

8. クリーンアップ(省略可)

クリーンアップするには、Cloud コンソールの Cloud Spanner セクションに移動し、Codelab のステップ「Cloud Spanner インスタンスを設定する」で作成した「cloudspanner-gaming」インスタンスを削除します。

9. 完了

これで、Spanner にサンプルゲームが正常にデプロイされました。

次のステップ

このラボでは、golang ドライバを使用した Spanner の操作に関するさまざまなトピックについて学習しました。以下のような重要なコンセプトを理解するのに役立つ基礎知識を身につけることができます。

  • スキーマの設計
  • DML とミューテーション
  • Golang の使用

ゲームのバックエンドとして Spanner を使用する別の例については、Cloud Spanner Game Trading Post Codelab をご覧ください。