Cloud Spanner 游戏开发入门

1. 简介

Cloud Spanner 是一种全托管式可横向扩容的全球分布式关系型数据库服务,可提供 ACID 事务和 SQL 语义,同时兼顾性能和高可用性。

这些特性使得 Spanner 非常适合希望吸引全球玩家或担心数据一致性的游戏架构

在本实验中,您将创建两项与区域性 Spanner 数据库交互的 Go 服务,以便玩家注册并开始玩游戏。

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 ( console.cloud.google.com) 并创建一个新项目。

如果您已经有一个项目,请点击控制台左上方的项目选择下拉菜单:

6c9406d9b014760.png

然后在出现的对话框中点击“新建项目”按钮以创建一个新项目:

949d83c8a4ee17d9.png

如果您还没有项目,则应该看到一个类似这样的对话框来创建您的第一个项目:

870a3cbd6541ee86.png

随后的项目创建对话框可让您输入新项目的详细信息:

6a92c57d3250a4b3.png

请记住项目 ID,它在所有 Google Cloud 项目中都是唯一的名称(上述名称已被占用,您无法使用,抱歉!)。它稍后将在此 Codelab 中被称为 PROJECT_ID。

接下来,如果尚未执行此操作,则需要在 Developers Console 中启用结算功能,以便使用 Google Cloud 资源并启用 Cloud Spanner API

15d0ef27a8fbab27.png

在此 Codelab 中运行不会花费您超过几美元,但是如果您决定使用更多的资源或让它们运行(请参阅本文档末尾的“清理”部分),则可能会花费更多。如需了解 Google Cloud Spanner 价格,请参阅此处

Google Cloud Platform 的新用户均有资格获享 $300 赠金,免费试用此 Codelab。

Google Cloud Shell 设置

虽然 Google Cloud 和 Spanner 可以从笔记本电脑远程操作,但在此 Codelab 中,我们将使用 Google Cloud Shell,这是一个在云端运行的命令行环境。

基于 Debian 的这个虚拟机已加载了您需要的所有开发工具。它提供了一个持久的 5GB 主目录,并且在 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 Console 信息中心查找该 ID:

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 中,我们将在“generators”目录中重点介绍 2 个不同的负载测试:

  • authentication_server.py:包含用于创建玩家的任务,以及用于获取随机玩家以模拟单点查找的任务。
  • match_server.py:包含用于创建游戏和关闭游戏的任务。创建游戏时,系统会随机分配 100 名当前未在玩游戏的玩家。结束游戏会更新 games_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 实例

在此步骤中,我们会为此 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 允许在单个实例上创建多个数据库。

您可以在数据库中定义架构。您还可以控制哪些人有权访问数据库、设置自定义加密、配置优化器和设置保留期限。

在多区域实例上,您还可以配置默认主要区域。详细了解 Spanner 上的数据库。

在此 Codelab 中,您将使用默认选项创建数据库,并在创建时提供架构。

本实验将创建两个表:playersgames

77651ac12e47fe2a.png

玩家可以随时参与多场游戏,但一次只能参与一场游戏。玩家还拥有 JSON 数据类型统计信息,用于跟踪玩过的游戏赢得的游戏等有趣的数据。由于以后可能会添加其他统计信息,因此对于玩家而言,这实际上是一个无架构列。

游戏使用 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 中设置一些环境变量,以便在后续的实验中使用。因此,请记下实例 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. 部署 Profile Service

服务概览

个人资料服务是一个使用 gin 框架以 Go 编写的 REST API。

4fce45ee6c858b3e.png

在此 API 中,玩家可以注册玩游戏。这是通过一个简单的 POST 命令创建的,该命令接受玩家姓名、电子邮件地址和密码。密码使用 bcrypt 加密,哈希存储在数据库中。

电子邮件地址被视为唯一标识符,而 player_name 则用于在游戏中显示。

此 API 目前不处理登录,但您可以自行实现此功能,作为一项额外的练习。

Profile Service 的 ./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、电子邮件地址统计数据

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 上运行服务。

有了这些信息,就可以运行服务了。

运行 Profile Service

使用 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 名当前未在玩游戏的玩家。

当游戏关闭时,系统会随机选择一位获胜者,并调整每位玩家的游戏场次获胜场次统计数据。此外,还会更新每位玩家的状态,以表明他们不再玩游戏,因此可以参加未来的游戏。

配对服务的 ./src/golang/matchmaking-service/main.go 文件与 profile 服务采用类似的设置和代码,因此此处不再重复。此服务公开了两个主要端点,如下所示:

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 mutation 来创建游戏并分配玩家,因为对于大型更改,mutation 比 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 方式完成的。

关闭游戏稍微复杂一些。它包括在游戏玩家中随机选择一名获胜者、标记游戏结束时间,以及更新每位玩家的游戏次数获胜次数统计信息。

由于这种复杂性和变化量,我们再次选择突变来结束游戏。

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

为避免与 profile-service 发生冲突,此服务默认在 localhost:8081 上运行。

有了这些信息,现在可以运行配对服务了。

运行配对服务

使用 go 命令运行服务。这将建立在端口 8082 上运行的服务。此服务与 profile-service 有许多相同的依赖项,因此不会下载新的依赖项。

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"

摘要

在此步骤中,您部署了 matchmaking-service 来处理创建游戏和为游戏分配玩家的操作。此服务还会处理游戏结束,即随机选择一名获胜者,并更新所有游戏玩家的 games_playedgames_won 统计数据。

后续步骤

现在,您的服务已开始运行,接下来需要吸引玩家注册并畅玩游戏!

6. 开始播放

现在,个人资料和配对服务已在运行,您可以使用提供的 Locust 生成器来生成负载。

Locust 提供了一个用于运行生成器的 Web 界面,但在此实验中,您将使用命令行(–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'")

玩家名称、电子邮件地址和密码都是随机生成的。

成功注册的玩家将由第二个任务检索,以生成读取负载。

   @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 文件,该文件将以每次两个线程的并发性 (u=2) 生成新玩家 30 秒 (t=30s)

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

{"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 Console 的 Cloud Spanner 部分,然后删除我们在名为“设置 Cloud Spanner 实例”的 Codelab 步骤中创建的 cloudspanner-gaming 实例。

9. 恭喜!

恭喜!您已成功在 Spanner 上部署示例游戏

后续操作

在本实验中,您已了解使用 Golang 驱动程序处理 Spanner 的各种主题。它应该能让您更好地了解以下关键概念:

  • 架构设计
  • DML 与变更
  • 使用 Golang

请务必查看 Cloud Spanner 游戏交易站 Codelab,了解将 Spanner 用作游戏后端的另一个示例!