1. 簡介
Cloud Spanner 是全代管且遍及全球的關聯資料庫服務,提供 ACID 交易和 SQL 語意,但不會犧牲效能及高可用性。
有了這些功能,Spanner 就能完美融入整體玩家群,在遊戲架構中有助於拓展全球玩家,或是擔心資料一致性
在本研究室中,您將建立兩個 Go 服務,並與區域 Spanner 資料庫互動,讓玩家註冊並開始玩。
接下來,您會使用 Python 載入架構 Locust.io 產生資料,以模擬玩家註冊及玩遊戲。接著,您會查詢 Spanner 決定現在玩家人數,以及玩家的比賽勝場和比賽。
最後,您將清除在這個研究室中建立的資源。
建構項目
在這個研究室中,您將完成以下工作:
- 可建立 Spanner 執行個體
- 部署以 Go 編寫的「個人資料」服務處理玩家註冊
- 部署以 Go 編寫的配對服務,將玩家指派給遊戲、決定優勝者並更新玩家遊戲統計資料。
課程內容
- 如何設定 Cloud Spanner 執行個體
- 如何建立遊戲資料庫和結構定義
- 如何部署 Go 應用程式以與 Cloud Spanner 搭配使用
- 如何使用 Locust 產生資料
- 如何在 Cloud Spanner 中查詢資料,回答遊戲和玩家的相關問題。
軟硬體需求
2. 設定和需求
建立專案
如果您還沒有 Google 帳戶 (Gmail 或 Google Apps),請先建立帳戶。登入 Google Cloud Platform 控制台 ( console.cloud.google.com),並建立新專案。
如果您已有專案,請按一下控制台左上方的專案選取下拉式選單:
並點選 [新增專案]按鈕,用於建立新專案:
如果您還沒有專案,系統會顯示如下的對話方塊,讓您建立第一個專案:
後續的專案建立對話方塊可讓您輸入新專案的詳細資料:
請記住,專案 ID 在所有的 Google Cloud 專案中是不重複的名稱 (已經有人使用上述名稱,目前無法為您解決問題!)。稍後在本程式碼研究室中會稱為 PROJECT_ID。
接下來,如果您尚未在 Developers Console 中啟用計費功能,必須完成此步驟,才能使用 Google Cloud 資源並啟用 Cloud Spanner API。
執行本程式碼研究室所需的費用不應超過數美元,但如果您決定使用更多資源,或讓這些資源繼續運作,費用會增加 (請參閱本文件結尾的「清理」一節)。如需 Google Cloud Spanner 的定價資訊,請參閱這裡。
Google Cloud Platform 的新使用者符合 $300 美元的免費試用資格,應該可以免費使用本程式碼研究室。
Google Cloud Shell 設定
雖然 Google Cloud 和 Spanner 可以在筆記型電腦上遠端運作,但在本程式碼研究室中,我們會使用 Google Cloud Shell,這是一種在 Cloud 中執行的指令列環境。
這種以 Debian 為基礎的虛擬機器,搭載各種您需要的開發工具。提供永久的 5 GB 主目錄,而且在 Google Cloud 中運作,大幅提高網路效能和驗證能力。換言之,本程式碼研究室只需要在 Chromebook 上運作即可。
- 如要透過 Cloud 控制台啟用 Cloud Shell,只要點選「啟用 Cloud Shell」圖示 ,即可佈建並連線至環境,操作只需幾分鐘的時間。
連線至 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 控制台資訊主頁查詢:
根據預設,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 執行個體。請在左上方的漢堡選單 搜尋 Spanner 項目,或按下「/」搜尋 Spanner然後輸入「Spanner」
接著點選 ,然後為執行個體輸入執行個體名稱 cloudspanner-gaming
、選擇配置 (選取 us-central1
等區域執行個體),然後設定節點數量,藉此填寫表單。在本程式碼研究室中,只需要 500 processing units
。
最後,點選 [建立]而且您能在幾秒內使用 Cloud Spanner 執行個體
建立資料庫和結構定義
執行個體開始運作後,您就能建立資料庫。Spanner 允許在單一執行個體上使用多個資料庫。
您可以在資料庫中定義結構定義。您也可以控管有權存取資料庫的使用者、設定自訂加密功能、設定最佳化器,以及設定保留期限。
如果是多區域執行個體,您也可以設定預設主要版本。進一步瞭解 Spanner 中的資料庫。
在這個程式碼研究室中,您將使用預設選項建立資料庫,並在建立時提供結構定義。
本研究室將建立「玩家」和「遊戲」這兩個表格。
玩家可以隨著時間參與許多遊戲,但一次只能參與一個遊戲。玩家也可以將「統計資料」做為 JSON 資料類型,追蹤 games_played 和 games_won 等有趣的統計資料。由於其他統計資料稍後可能會新增,因此實際上這是玩家沒有結構定義的資料欄。
遊戲可使用 Spanner 的 ARRAY 資料類型追蹤參與的玩家。遊戲結束之前,系統不會填入遊戲的獲勝和已完成屬性。
有一組外鍵可確保玩家的 current_game 是有效的遊戲。
現在,請點選「建立資料庫」來建立資料庫執行個體總覽中:
然後填入詳細資料。重要選項為資料庫名稱和方言。在這個例子中,我們將資料庫命名為 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);
接著,按一下「建立」按鈕,等待幾秒鐘,等待系統建立資料庫。
建立資料庫頁面應如下所示:
現在,您必須在 Cloud Shell 中設定一些環境變數,才能在後續的程式碼研究室中使用。請記下 instance-id,然後在 Cloud Shell 中設定 INSTANCE_ID 和 DATABASE_ID
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 架構。
玩家可以透過這個 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()
}
}
Player 和 PlayerStats 則是結構體,定義如下:
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、email 和 stats。
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 架構。
在這個 API 中,系統會建立並關閉遊戲。建立遊戲時,系統會將 10 位目前未玩遊戲的玩家指派給遊戲。
遊戲結束時,系統會隨機選出得獎者,每位玩家games_played 和 games_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 結構,以及經過修改的 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 效能優於 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_played 和 games_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 中開啟新的終端機:
接著請發出以下 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_played 和 games_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 發出查詢要求。
查看公開與封閉式遊戲
封閉式遊戲的時間戳記已填入「已完成」,開放式遊戲則「已完成」為空值。這個值會在遊戲關閉時設定,
因此,這個查詢專門用來查看已開啟和關閉的賽事數量:
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
)
結果:
|
|
|
|
|
|
比較玩遊戲和不玩遊戲的玩家人數
如果設定「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
)
結果:
|
|
|
|
|
|
選出最佳得獎者
比賽結束後,系統會隨機選出一名玩家成為得獎者,在遊戲結束之後,該玩家的 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 | {"games_played":49,"games_won":1} |
09b72595-40af-4406-a000-2fb56c58fe92 | {"games_played":56,"games_won":1} |
1002385b-02a0-462b-a8e7-05c9b27223aa | {"games_played":66,"games_won":1} |
13ec3770-7ae3-495f-9b53-6322d8e8d6c3 | {"games_played":44,"games_won":1} |
15513852-3f2a-494f-b437-fe7125d15f1b | {"games_played":49,"games_won":1} |
17faec64-4f77-475c-8df8-6ab026cf6698 | {"games_played":50,"games_won":1} |
1abfcb27-037d-446d-bb7a-b5cd17b5733d | {"games_played":63,"games_won":1} |
2109a33e-88bd-4e74-a35c-a7914d9e3bde | {"games_played":56,"games_won":2} |
222e37d9-06b0-4674-865d-a0e5fb80121e | {"games_played":60,"games_won":1} |
22ced15c-0da6-4fd9-8cb2-1ffd233b3c56 | {"games_played":50,"games_won":1} |
摘要
在這個步驟中,您使用 Cloud 控制台查詢 Spanner,檢視玩家和遊戲的各種統計資料。
後續步驟
接下來是清理儲存空間的好時機!
8. 清除所用資源 (選用)
如要清理資源,請前往 Cloud 控制台的 Cloud Spanner 專區,然後刪除我們在「設定 Cloud Spanner 執行個體」程式碼研究室步驟中建立的 cloudspanner-gaming 執行個體。
9. 恭喜!
恭喜!您已成功在 Spanner 中部署範例遊戲
後續步驟
本研究室介紹了各種使用 golang 驅動程式與 Spanner 搭配使用的主題。這應該能奠定更完善的基礎,協助您瞭解下列重要概念:
- 結構定義設計
- DML 與變異
- 使用 Golang
請務必查看 Cloud Spanner 遊戲交易貼文程式碼研究室,瞭解另一個使用 Spanner 做為遊戲後端的範例!