Cloud Spanner 開始使用遊戲開發

1. 簡介

Cloud Spanner 是全代管且遍及全球的關聯資料庫服務,提供 ACID 交易和 SQL 語意,但不會犧牲效能及高可用性。

有了這些功能,Spanner 就能完美融入整體玩家群,在遊戲架構中有助於拓展全球玩家,或是擔心資料一致性

在本研究室中,您將建立兩個 Go 服務,並與區域 Spanner 資料庫互動,讓玩家註冊並開始玩。

413fdd57bb0b68bc.png

接下來,您會使用 Python 載入架構 Locust.io 產生資料,以模擬玩家註冊及玩遊戲。接著,您會查詢 Spanner 決定現在玩家人數,以及玩家的比賽勝場和比賽。

最後,您將清除在這個研究室中建立的資源。

建構項目

在這個研究室中,您將完成以下工作:

  • 可建立 Spanner 執行個體
  • 部署以 Go 編寫的「個人資料」服務處理玩家註冊
  • 部署以 Go 編寫的配對服務,將玩家指派給遊戲、決定優勝者並更新玩家遊戲統計資料。

課程內容

  • 如何設定 Cloud Spanner 執行個體
  • 如何建立遊戲資料庫和結構定義
  • 如何部署 Go 應用程式以與 Cloud Spanner 搭配使用
  • 如何使用 Locust 產生資料
  • 如何在 Cloud Spanner 中查詢資料,回答遊戲和玩家的相關問題。

軟硬體需求

  • 連結至帳單帳戶的 Google Cloud 專案。
  • 使用網路瀏覽器,例如 ChromeFirefox

2. 設定和需求

建立專案

如果您還沒有 Google 帳戶 (Gmail 或 Google Apps),請先建立帳戶。登入 Google Cloud Platform 控制台 ( console.cloud.google.com),並建立新專案。

如果您已有專案,請按一下控制台左上方的專案選取下拉式選單:

6c9406d9b014760.png

並點選 [新增專案]按鈕,用於建立新專案:

949d83c8a4ee17d9.png

如果您還沒有專案,系統會顯示如下的對話方塊,讓您建立第一個專案:

870a3cbd6541ee86.png

後續的專案建立對話方塊可讓您輸入新專案的詳細資料:

6a92c57d3250a4b3.png

請記住,專案 ID 在所有的 Google Cloud 專案中是不重複的名稱 (已經有人使用上述名稱,目前無法為您解決問題!)。稍後在本程式碼研究室中會稱為 PROJECT_ID。

接下來,如果您尚未在 Developers Console 中啟用計費功能,必須完成此步驟,才能使用 Google Cloud 資源並啟用 Cloud Spanner API

15d0ef27a8fbab27.png

執行本程式碼研究室所需的費用不應超過數美元,但如果您決定使用更多資源,或讓這些資源繼續運作,費用會增加 (請參閱本文件結尾的「清理」一節)。如需 Google Cloud Spanner 的定價資訊,請參閱這裡

Google Cloud Platform 的新使用者符合 $300 美元的免費試用資格,應該可以免費使用本程式碼研究室。

Google Cloud Shell 設定

雖然 Google Cloud 和 Spanner 可以在筆記型電腦上遠端運作,但在本程式碼研究室中,我們會使用 Google Cloud Shell,這是一種在 Cloud 中執行的指令列環境。

這種以 Debian 為基礎的虛擬機器,搭載各種您需要的開發工具。提供永久的 5 GB 主目錄,而且在 Google Cloud 中運作,大幅提高網路效能和驗證能力。換言之,本程式碼研究室只需要在 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 端點。在這個程式碼研究室中,我們在「產生器」中提供 2 種不同的負載測試將醒目顯示的是

  • authentication_server.py:包含建立玩家的工作,以及讓隨機玩家模擬單點查詢作業的工作。
  • match_server.py:包含建立遊戲與關閉遊戲的工作。建立遊戲時,系統會隨機指派 100 名目前沒有在玩遊戲的玩家。關閉遊戲後,系統會更新 game_played 和 Games_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

現在請更新 PATH,以便找到新安裝的 locust 二進位檔:

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

指令輸出

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

摘要

您會在這個步驟中設定專案 (如果尚未設定、已啟用的 Cloud Shell),並下載這個研究室的程式碼。

最後,在研究室的後續部分,您會設定 Locust 產生負載。

下一步

接下來,您將設定 Cloud Spanner 執行個體和資料庫。

3. 建立 Spanner 執行個體和資料庫

建立 Spanner 執行個體

在這個步驟中,我們會為程式碼研究室設定 Spanner 執行個體。請在左上方的漢堡選單 1a6580bd3d3e6783.png3129589f7bc9e5ce.png 搜尋 Spanner 項目,或按下「/」搜尋 Spanner然後輸入「Spanner」

36e52f8df8e13b99.png

接著點選 95269e75bc8c3e4d.png,然後為執行個體輸入執行個體名稱 cloudspanner-gaming、選擇配置 (選取 us-central1 等區域執行個體),然後設定節點數量,藉此填寫表單。在本程式碼研究室中,只需要 500 processing units

最後,點選 [建立]而且您能在幾秒內使用 Cloud Spanner 執行個體

4457c324c94f93e6.png

建立資料庫和結構定義

執行個體開始運作後,您就能建立資料庫。Spanner 允許在單一執行個體上使用多個資料庫。

您可以在資料庫中定義結構定義。您也可以控管有權存取資料庫的使用者、設定自訂加密功能、設定最佳化器,以及設定保留期限。

如果是多區域執行個體,您也可以設定預設主要版本。進一步瞭解 Spanner 中的資料庫。

在這個程式碼研究室中,您將使用預設選項建立資料庫,並在建立時提供結構定義。

本研究室將建立「玩家」和「遊戲」這兩個表格。

77651ac12e47fe2a.png

玩家可以隨著時間參與許多遊戲,但一次只能參與一個遊戲。玩家也可以將「統計資料」做為 JSON 資料類型,追蹤 games_playedgames_won 等有趣的統計資料。由於其他統計資料稍後可能會新增,因此實際上這是玩家沒有結構定義的資料欄。

遊戲可使用 Spanner 的 ARRAY 資料類型追蹤參與的玩家。遊戲結束之前,系統不會填入遊戲的獲勝和已完成屬性。

有一組外鍵可確保玩家的 current_game 是有效的遊戲。

現在,請點選「建立資料庫」來建立資料庫執行個體總覽中:

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

現在,您必須在 Cloud Shell 中設定一些環境變數,才能在後續的程式碼研究室中使用。請記下 instance-id,然後在 Cloud Shell 中設定 INSTANCE_ID 和 DATABASE_ID

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. 部署設定檔服務

服務總覽

設定檔服務是利用 Go 編寫的 REST API,並利用 Gin 架構。

4fce45ee6c858b3e.png

玩家可以透過這個 API 註冊玩遊戲。這會透過簡單的 POST 指令建立,可接受玩家名稱、電子郵件和密碼。密碼會以 bcrypt 加密,雜湊則儲存在資料庫中。

系統會將「Email」視為專屬 ID,而 player_name 則是用於顯示遊戲。

此 API 目前無法處理登入,但您可以視做其他練習。

設定檔服務的 ./src/golang/profile-service/main.go 檔案公開兩個主要端點,如下所示:

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

服務的首要事項之一,就是設定 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. 部署配對服務

服務總覽

配對服務是採用 Go 編寫的 REST API,並運用 Gin 架構。

9aecd571df0dcd7c.png

在這個 API 中,系統會建立關閉遊戲。建立遊戲時,系統會將 10 位目前未玩遊戲的玩家指派給遊戲。

遊戲結束時,系統會隨機選出得獎者,每位玩家games_playedgames_won 的統計資料會經過調整。此外,每位玩家都已停止玩遊戲,日後也能繼續玩遊戲。

配對服務的 ./src/golang/matchmaking-service/main.go 檔案採用與「設定檔」服務類似的設定和程式碼,因此在此時不會重複。這個服務會公開兩個主要端點,如下所示:

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 結構,以及經過修改的 PlayerPlayerStats 結構體:

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 效能優於 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 提供網頁介面以執行產生器,但在本研究室中,您將使用指令列 (– 無選項)。

註冊玩家

首先是產生玩家。

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

玩家名稱、電子郵件和密碼是隨機產生。

成功註冊的玩家將由第二個工作擷取,以便產生讀取負載。

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

下列指令會呼叫 ./generators/authentication_server.py 檔案,這樣可以一次產生 30 秒 (t=30s) 的新玩家,同時並行兩個執行緒 (u=2):

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

加入遊戲的玩家

你已成功註冊玩家,他們也可以開始玩遊戲!

./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 (open:close) 的比例開啟及關閉遊戲。這個指令將執行產生器,持續 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

查看公開與封閉式遊戲

封閉式遊戲的時間戳記已填入「已完成」,開放式遊戲則「已完成」為空值。這個值會在遊戲關閉時設定,

因此,這個查詢專門用來查看已開啟和關閉的賽事數量:

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」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 專區,然後刪除我們在「設定 Cloud Spanner 執行個體」程式碼研究室步驟中建立的 cloudspanner-gaming 執行個體。

9. 恭喜!

恭喜!您已成功在 Spanner 中部署範例遊戲

後續步驟

本研究室介紹了各種使用 golang 驅動程式與 Spanner 搭配使用的主題。這應該能奠定更完善的基礎,協助您瞭解下列重要概念:

  • 結構定義設計
  • DML 與變異
  • 使用 Golang

請務必查看 Cloud Spanner 遊戲交易貼文程式碼研究室,瞭解另一個使用 Spanner 做為遊戲後端的範例!