Cloud Spanner 開始使用遊戲開發

Cloud Spanner 開始使用遊戲開發

程式碼研究室簡介

subject上次更新時間:5月 1, 2023
account_circle作者:Derek Downey, Aalok Muley

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 做為遊戲後端的範例!