Cloud Spanner 游戏交易帖子

1. 简介

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

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

在此实验中,您将创建两项 Go 服务,这两项服务将与区域性 Spanner 数据库交互,以便玩家能够获取物品和金钱 (item-service),然后在交易站上列出物品供其他玩家购买 (tradepost-service)。

本实验依赖于 Cloud Spanner 游戏开发入门 Codelab,以便使用 profile-servicematchmaking-service 生成玩家和游戏。

904c5193ee27626a.png

接下来,您将利用 Python 负载框架 Locust.io 生成数据,以模拟玩家在“游戏”过程中获取金钱和物品。然后,玩家可以在交易站上列出待售物品,其他有足够金钱的玩家可以购买这些物品。

您还将查询 Spanner,以确定玩家的账号余额和商品数量,以及有关未完成或已完成的交易订单的一些统计信息。

最后,您将清理在本实验中创建的资源。

构建内容

在本实验中,您将:

  • 重复使用 Cloud Spanner 游戏开发入门中的 Spanner 实例。
  • 部署以 Go 编写的 Item 服务,以处理玩家获取物品和金钱的操作
  • 部署以 Go 编写的交易站服务,以模拟玩家列出待售商品以及其他玩家购买这些商品。

学习内容

  • 如何使用读写事务来确保数据更改的一致性
  • 如何利用 DML 和 Spanner 变更来修改数据

所需条件

2. 设置和要求

完成 Cloud Spanner 游戏开发入门 Codelab

完成 Cloud Spanner 游戏开发入门 Codelab。这是获取玩家和游戏数据集所必需的。玩家和游戏需要获取物品和金钱,然后才能列出待售物品并从交易站购买物品。

在 Cloud Shell 中配置环境变量

点击“激活 Cloud Shell”图标 gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A 打开 Cloud Shell(由于您之前已完成此操作,因此配置和连接到环境应该只需要片刻时间)。

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。

在 Cloud Shell 中设置 SPANNER 环境变量

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

创建架构

现在,您已创建数据库,接下来可以在 sample-game 数据库中定义架构。

在此实验中,您将创建四个新表:game_itemsplayer_itemsplayer_ledger_entriestrade_orders

402ce3310dd7141a.png

玩家与商品之间的关系

5e1229d85b75906.png

贸易订单关系

游戏物品会添加到 game_items 表中,然后玩家就可以获取这些物品。player_items 表同时具有 itemUUID 和 playerUUID 的外键,以确保玩家仅获取有效物品。

player_ledger_entries 表会跟踪玩家账号余额的所有货币变动。这可能是通过拾取战利品或在交易站出售物品来获取资金。

最后,trade_orders 表用于处理发布卖出订单,以及买家完成这些订单。

如需创建架构,请点击 Cloud 控制台中的 Write DDL 按钮:

e9ad931beb1d96b.png

在此处,您将输入 schema/trading.sql 文件中的架构定义:

CREATE TABLE game_items
(
  itemUUID STRING(36) NOT NULL,
  item_name STRING(MAX) NOT NULL,
  item_value NUMERIC NOT NULL,
  available_time TIMESTAMP NOT NULL,
  duration int64
)PRIMARY KEY (itemUUID);

CREATE TABLE player_items
(
  playerItemUUID STRING(36) NOT NULL,
  playerUUID STRING(36) NOT NULL,
  itemUUID STRING(36) NOT NULL,
  price NUMERIC NOT NULL,
  source STRING(MAX) NOT NULL,
  game_session STRING(36) NOT NULL,
  acquire_time TIMESTAMP NOT NULL DEFAULT (CURRENT_TIMESTAMP()),
  expires_time TIMESTAMP,
  visible BOOL NOT NULL DEFAULT(true),
  FOREIGN KEY (itemUUID) REFERENCES game_items (itemUUID),
  FOREIGN KEY (game_session) REFERENCES games (gameUUID)
) PRIMARY KEY (playerUUID, playerItemUUID),
    INTERLEAVE IN PARENT players ON DELETE CASCADE;

CREATE TABLE player_ledger_entries (
  playerUUID STRING(36) NOT NULL,
  source STRING(MAX) NOT NULL,
  game_session STRING(36) NOT NULL,
  amount NUMERIC NOT NULL,
  entryDate TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
  FOREIGN KEY (game_session) REFERENCES games (gameUUID)
) PRIMARY KEY (playerUUID, entryDate DESC),
  INTERLEAVE IN PARENT players ON DELETE CASCADE;

CREATE TABLE trade_orders
(
  orderUUID STRING(36)  NOT NULL,
  lister STRING(36) NOT NULL,
  buyer STRING(36),
  playerItemUUID STRING(36) NOT NULL,
  trade_type STRING(5) NOT NULL,
  list_price NUMERIC NOT NULL,
  created TIMESTAMP NOT NULL DEFAULT (CURRENT_TIMESTAMP()),
  ended TIMESTAMP,
  expires TIMESTAMP NOT NULL DEFAULT (TIMESTAMP_ADD(CURRENT_TIMESTAMP(), interval 24 HOUR)),
  active BOOL NOT NULL DEFAULT (true),
  cancelled BOOL NOT NULL DEFAULT (false),
  filled BOOL NOT NULL DEFAULT (false),
  expired BOOL NOT NULL DEFAULT (false),
  FOREIGN KEY (playerItemUUID) REFERENCES player_items (playerItemUUID)
) PRIMARY KEY (orderUUID);

CREATE INDEX TradeItem ON trade_orders(playerItemUUID, active);

点击“Submit”按钮修改架构,然后等待架构更新完成:

94f44b2774bce914.png

后续步骤

接下来,您将部署商品服务。

3. 部署商品服务

服务概览

商品服务是一个使用 Gin 框架以 Go 编写的 REST API。在此 API 中,参与开放游戏的玩家可以获得金钱和物品。

6c29a0831b5f588d.png

./src/golang/item-service/main.go 文件配置了以下端点,以处理游戏物品和获取这些物品的玩家。此外,还有一个供玩家获取金钱的端点。

 func main() {
   configuration, _ := config.NewConfig()

   router := gin.Default()
   router.SetTrustedProxies(nil)
   router.Use(setSpannerConnection(configuration))

   router.GET("/items", getItemUUIDs)
   router.POST("/items", createItem)
   router.GET("/items/:id", getItem)
   router.PUT("/players/balance", updatePlayerBalance) 
   router.GET("/players", getPlayer)
   router.POST("/players/items", addPlayerItem)

   router.Run(configuration.Server.URL())
}

Spanner 连接的配置和使用方式与上一个 Codelab 中的 profile-service 和 matchmaking-service 完全相同。

商品服务可与 GameItem、Player、PlayerLedgerPlayerItem 搭配使用,并具有以下定义:

// models/game_items.go 
type GameItem struct {
   ItemUUID       string    `json:"itemUUID"`
   Item_name      string    `json:"item_name"`
   Item_value     big.Rat   `json:"item_value"`
   Available_time time.Time `json:"available_time"`
   Duration       int64     `json:"duration"`
}

// models/players.go 
type Player struct {
   PlayerUUID      string    `json:"playerUUID" binding:"required,uuid4"`
   Updated         time.Time `json:"updated"`
   Account_balance big.Rat   `json:"account_balance"`
   Current_game    string    `json:"current_game"`
}

type PlayerLedger struct {
   PlayerUUID   string  `json:"playerUUID" binding:"required,uuid4"`
   Amount       big.Rat `json:"amount"`
   Game_session string  `json:"game_session"`
   Source       string  `json:"source"`
}

// models/player_items.go 
type PlayerItem struct {
   PlayerItemUUID string           `json:"playerItemUUID" binding:"omitempty,uuid4"`
   PlayerUUID     string           `json:"playerUUID" binding:"required,uuid4"`
   ItemUUID       string           `json:"itemUUID" binding:"required,uuid4"`
   Source         string           `json:"source" binding:"required"`
   Game_session   string           `json:"game_session" binding:"omitempty,uuid4"`
   Price          big.Rat          `json:"price"`
   AcquireTime    time.Time        `json:"acquire_time"`
   ExpiresTime    spanner.NullTime `json:"expires_time"`
   Visible        bool             `json:"visible"`
}

首先,游戏必须已创建一些商品。为此,系统会向 /items 端点发出 POST 请求。这是一个非常简单的 DML 插入,用于将数据插入到 game_items 表中。

 // main.go
func createItem(c *gin.Context) {
   var item models.GameItem

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

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

   c.IndentedJSON(http.StatusCreated, item.ItemUUID)
}

// models/game_items.go
func (i *GameItem) Create(ctx context.Context, client spanner.Client) error {
   // Initialize item values
   i.ItemUUID = generateUUID()

   if i.Available_time.IsZero() {
       i.Available_time = time.Now()
   }

   // insert into spanner
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       stmt := spanner.Statement{
           SQL: `INSERT game_items (itemUUID, item_name, item_value, available_time, duration)
                 VALUES (@itemUUID, @itemName, @itemValue, @availableTime, @duration)
           `,
           Params: map[string]interface{}{
               "itemUUID":      i.ItemUUID,
               "itemName":      i.Item_name,
               "itemValue":     i.Item_value,
               "availableTime": i.Available_time,
               "duration":      i.Duration,
           },
       }

       _, err := txn.Update(ctx, stmt)
       return err
   })

   if err != nil {
       return err
   }

   // return empty error on success
   return nil
}

如需获取商品,请调用针对 /players/items 端点的 POST 请求。此端点的逻辑是检索游戏商品的当前价值以及玩家的当前游戏会话。然后,将相应信息插入 player_items 表中,指明商品获取来源和时间。

这对应于以下函数:

 // main.go
func addPlayerItem(c *gin.Context) {
   var playerItem models.PlayerItem

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

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

   c.IndentedJSON(http.StatusCreated, playerItem)
}

// models/player_items.go
func (pi *PlayerItem) Add(ctx context.Context, client spanner.Client) error {
   // insert into spanner
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       // Get item price at time of transaction
       price, err := GetItemPrice(ctx, txn, pi.ItemUUID)
       if err != nil {
           return err
       }

       pi.Price = price

       // Get Game session
       session, err := GetPlayerSession(ctx, txn, pi.PlayerUUID)
       if err != nil {
           return err
       }

       pi.Game_session = session

       pi.PlayerItemUUID = generateUUID()

       // Insert
       cols := []string{"playerItemUUID", "playerUUID", "itemUUID", "price", "source", "game_session"}

       txn.BufferWrite([]*spanner.Mutation{
           spanner.Insert("player_items", cols,
               []interface{}{pi.PlayerItemUUID, pi.PlayerUUID, pi.ItemUUID, pi.Price, pi.Source, pi.Game_session}),
       })

       return nil
   })

   if err != nil {
       return err
   }

   // return empty error on success
   return nil
}

为了让玩家获得金钱,系统会调用针对 /players/updatebalance 端点的 PUT 请求。

此端点的逻辑是在应用金额后更新玩家的余额,并使用获取记录更新 player_ledger_entries 表。修改玩家的 account_balance 以返回给调用方。DML 用于修改 player 和 player_ledger_entries。

这对应于以下函数:

 // main.go
func updatePlayerBalance(c *gin.Context) {
   var player models.Player
   var ledger models.PlayerLedger

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

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

   type PlayerBalance struct {
       PlayerUUID, AccountBalance string
   }

   balance := PlayerBalance{PlayerUUID: player.PlayerUUID, AccountBalance: player.Account_balance.FloatString(2)}
   c.IndentedJSON(http.StatusOK, balance)
}

// models/players.go
func (l *PlayerLedger) UpdateBalance(ctx context.Context, client spanner.Client, p *Player) error {
   // Update balance with new amount
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       p.PlayerUUID = l.PlayerUUID
       stmt := spanner.Statement{
           SQL: `UPDATE players SET account_balance = (account_balance + @amount) WHERE playerUUID = @playerUUID`,
           Params: map[string]interface{}{
               "amount":     l.Amount,
               "playerUUID": p.PlayerUUID,
           },
       }
       numRows, err := txn.Update(ctx, stmt)

       if err != nil {
           return err
       }

       // No rows modified. That's an error
       if numRows == 0 {
           errorMsg := fmt.Sprintf("Account balance for player '%s' could not be updated", p.PlayerUUID)
           return errors.New(errorMsg)
       }

       // Get player's new balance (read after write)
       stmt = spanner.Statement{
           SQL: `SELECT account_balance, current_game FROM players WHERE playerUUID = @playerUUID`,
           Params: map[string]interface{}{
               "playerUUID": p.PlayerUUID,
           },
       }
       iter := txn.Query(ctx, stmt)
       defer iter.Stop()
       for {
           row, err := iter.Next()
           if err == iterator.Done {
               break
           }
           if err != nil {
               return err
           }
           var accountBalance big.Rat
           var gameSession string

           if err := row.Columns(&accountBalance, &gameSession); err != nil {
               return err
           }
           p.Account_balance = accountBalance
           l.Game_session = gameSession
       }

       stmt = spanner.Statement{
           SQL: `INSERT INTO player_ledger_entries (playerUUID, amount, game_session, source, entryDate)
               VALUES (@playerUUID, @amount, @game, @source, PENDING_COMMIT_TIMESTAMP())`,
           Params: map[string]interface{}{
               "playerUUID": l.PlayerUUID,
               "amount":     l.Amount,
               "game":       l.Game_session,
               "source":     l.Source,
           },
       }
       numRows, err = txn.Update(ctx, stmt)
       if err != nil {
           return err
       }

       return nil
   })

   if err != nil {
       return err
   }

   return nil
}

默认情况下,服务是使用环境变量配置的。请参阅 ./src/golang/item-service/config/config.go 文件的相关部分。

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

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

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

运行服务

运行该服务将下载依赖项,并建立在端口 8082 上运行的服务:

cd ~/spanner-gaming-sample/src/golang/item-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] GET    /items                    --> main.getItemUUIDs (4 handlers)
[GIN-debug] POST   /items                    --> main.createItem (4 handlers)
[GIN-debug] GET    /items/:id                --> main.getItem (4 handlers)
[GIN-debug] PUT    /players/balance          --> main.updatePlayerBalance (4 handlers)
[GIN-debug] GET    /players                  --> main.getPlayer (4 handlers)
[GIN-debug] POST   /players/items            --> main.addPlayerItem (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8082

通过发出 curl 命令来创建商品,测试服务:

curl http://localhost:8082/items \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"item_name": "test_item","item_value": "3.14"}'

命令输出

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <Date>
Content-Length: 38

"aecde380-0a79-48c0-ab5d-0da675d3412c"

接下来,您希望玩家获得此商品。为此,您需要 ItemUUIDPlayerUUIDItemUUID 是上一个命令的输出。在此示例中,该网址为:aecde380-0a79-48c0-ab5d-0da675d3412c

如需获取 PlayerUUID,请调用 GET /players 端点:

curl http://localhost:8082/players

命令输出

{
    "playerUUID": "b74cc194-87b0-4a55-a67f-0f0742ef6352",
    "updated": "0001-01-01T00:00:00Z",
    "account_balance": {},
    "current_game": "7b97fa85-5658-4ded-a962-4c09269a0a79"
}

为了让玩家获得相应商品,请向 POST /players/items 端点发出请求:

curl http://localhost:8082/players/items \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"playerUUID": "b74cc194-87b0-4a55-a67f-0f0742ef6352","itemUUID": "109ec745-9906-402b-9d03-ca7153a10312", "source": "loot"}'

命令输出

Content-Type: application/json; charset=utf-8
Date: <Date>
Content-Length: 369

{
    "playerItemUUID": "a42b1899-4509-4fce-9958-265d2a2838a0",
    "playerUUID": "b74cc194-87b0-4a55-a67f-0f0742ef6352",
    "itemUUID": "109ec745-9906-402b-9d03-ca7153a10312",
    "source": "loot",
    "game_session": "7b97fa85-5658-4ded-a962-4c09269a0a79",
    "price": {},
    "acquire_time": "0001-01-01T00:00:00Z",
    "expires_time": null,
    "visible": false
}

摘要

在此步骤中,您部署了商品服务,该服务允许创建游戏商品,并允许分配给开放游戏的玩家获取金钱和游戏商品。

后续步骤

在下一步中,您将部署 tradepost 服务。

4. 部署 tradepost 服务

服务概览

交易站服务是一个使用 Go 编写的 REST API,它利用了 gin 框架。在此 API 中,玩家商品会发布到 sell。然后,游戏玩家可以查看公开交易,如果他们有足够的资金,就可以购买该商品。

c32372f9def89a4a.png

交易平台服务的 ./src/golang/tradepost-service/main.go 文件与其他服务类似,因此此处不再赘述。此服务公开了以下几个端点:

 func main() {
   configuration, _ := config.NewConfig()

   router := gin.Default()
   router.SetTrustedProxies(nil)

   router.Use(setSpannerConnection(configuration))

   router.GET("/trades/player_items", getPlayerItem)
   router.POST("/trades/sell", createOrder)
   router.GET("/trades/open", getOpenOrder)
   router.PUT("/trades/buy", purchaseOrder)
   
   router.Run(configuration.Server.URL())
}

此服务提供 TradeOrder 结构体,以及 GameItem、PlayerItem、PlayerPlayerLedger 结构体所需的结构体:

 type TradeOrder struct {
   OrderUUID      string           `json:"orderUUID" binding:"omitempty,uuid4"`
   Lister         string           `json:"lister" binding:"omitempty,uuid4"`
   Buyer          string           `json:"buyer" binding:"omitempty,uuid4"`
   PlayerItemUUID string           `json:"playerItemUUID" binding:"omitempty,uuid4"`
   TradeType      string           `json:"trade_type"`
   ListPrice      big.Rat          `json:"list_price" spanner:"list_price"`
   Created        time.Time        `json:"created"`
   Ended          spanner.NullTime `json:"ended"`
   Expires        time.Time        `json:"expires"`
   Active         bool             `json:"active"`
   Cancelled      bool             `json:"cancelled"`
   Filled         bool             `json:"filled"`
   Expired        bool             `json:"expired"`
}

type GameItem struct {
   ItemUUID      string    `json:"itemUUID"`
   ItemName      string    `json:"item_name"`
   ItemValue     big.Rat   `json:"item_value"`
   AvailableTime time.Time `json:"available_time"`
   Duration      int64     `json:"duration"`
}

type PlayerItem struct {
   PlayerItemUUID string           `json:"playerItemUUID" binding:"omitempty,uuid4"`
   PlayerUUID     string           `json:"playerUUID" binding:"required,uuid4"`
   ItemUUID       string           `json:"itemUUID" binding:"required,uuid4"`
   Source         string           `json:"source"`
   GameSession    string           `json:"game_session" binding:"omitempty,uuid4"`
   Price          big.Rat          `json:"price"`
   AcquireTime    time.Time        `json:"acquire_time" spanner:"acquire_time"`
   ExpiresTime    spanner.NullTime `json:"expires_time" spanner:"expires_time"`
   Visible        bool             `json:"visible"`
}

type Player struct {
   PlayerUUID     string    `json:"playerUUID" binding:"required,uuid4"`
   Updated        time.Time `json:"updated"`
   AccountBalance big.Rat   `json:"account_balance" spanner:"account_balance"`
   CurrentGame    string    `json:"current_game" binding:"omitempty,uuid4" spanner:"current_game"`
}

type PlayerLedger struct {
   PlayerUUID  string  `json:"playerUUID" binding:"required,uuid4"`
   Amount      big.Rat `json:"amount"`
   GameSession string  `json:"game_session" spanner:"game_session"`
   Source      string  `json:"source"`
}

如需创建交易订单,请向 API 端点 /trades/sell 发出 POST 请求。所需信息包括要出售的 player_item 的 playerItemUUIDlisterlist_price

选择 Spanner mutation 来创建交易订单并将 player_item 标记为不可见。这样做可以防止卖家发布重复的待售商品。

 func (o *TradeOrder) Create(ctx context.Context, client spanner.Client) error {
   // insert into spanner
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       // get the Item to be listed
       pi, err := GetPlayerItem(ctx, txn, o.Lister, o.PlayerItemUUID)
       if err != nil {
           return err
       }

       // Set expires to 1 day by default
       if o.Expires.IsZero() {
           currentTime := time.Now()
           o.Expires = currentTime.Add(time.Hour * 24)
       }

       // Item is not visible or expired, so it can't be listed. That's an error
       if !validateSellOrder(pi) {
           errorMsg := fmt.Sprintf("Item (%s, %s) cannot be listed.", o.Lister, o.PlayerItemUUID)
           return errors.New(errorMsg)
       }

       // Initialize order values
       o.OrderUUID = generateUUID()
       o.Active = true // TODO: Have to set this by default since testing with emulator does not support 'DEFAULT' schema option

       // Insert the order
       var m []*spanner.Mutation
       cols := []string{"orderUUID", "playerItemUUID", "lister", "list_price", "trade_type", "expires", "active"}
       m = append(m, spanner.Insert("trade_orders", cols, []interface{}{o.OrderUUID, o.PlayerItemUUID, o.Lister, o.ListPrice, "sell", o.Expires, o.Active}))

       // Mark the item as invisible
       cols = []string{"playerUUID", "playerItemUUID", "visible"}
       m = append(m, spanner.Update("player_items", cols, []interface{}{o.Lister, o.PlayerItemUUID, false}))

       txn.BufferWrite(m)
       return nil
   })

   if err != nil {
       return err
   }

   // return empty error on success
   return nil
}

在实际创建订单之前,系统会验证 PlayerItem,以确保它可以上架销售。这主要意味着 PlayerItem 对玩家可见,并且尚未过期。

 // Validate that the order can be placed: Item is visible and not expired
func validateSellOrder(pi PlayerItem) bool {
   // Item is not visible, can't be listed
   if !pi.Visible {
       return false
   }

   // item is expired. can't be listed
   if !pi.ExpiresTime.IsNull() && pi.ExpiresTime.Time.Before(time.Now()) {
       return false
   }

   // All validation passed. Item can be listed
   return true
}

通过向 /trades/buy 端点发出 PUT 请求来完成购买。所需信息包括 orderUUIDbuyer(即进行购买的玩家的 UUID)。

由于这种复杂性和更改量,系统再次选择突变来购买订单。以下操作在单个读写事务中完成:

  • 验证订单是否可以完成,因为该订单之前未完成且未过期。
 // Validate that the order can be filled: Order is active and not expired
func validatePurchase(o TradeOrder) bool {
   // Order is not active
   if !o.Active {
       return false
   }

   // order is expired. can't be filled
   if !o.Expires.IsZero() && o.Expires.Before(time.Now()) {
       return false
   }

   // All validation passed. Order can be filled
   return true
}
  • 检索买家信息,并验证买家是否可以购买相应商品。这意味着买方不能与房源发布者相同,并且买方有足够的资金。
 // Validate that a buyer can buy this item.
func validateBuyer(b Player, o TradeOrder) bool {
   // Lister can't be the same as buyer
   if b.PlayerUUID == o.Lister {
       return false
   }

   // Big.rat returns -1 if Account_balance is less than price
   if b.AccountBalance.Cmp(&o.ListPrice) == -1 {
       return false
   }

   return true
}
  • 将订单的 list_price 添加到卖家的账号余额,并添加相应的账簿条目。
 // models/trade_order.go
// Buy an order
func (o *TradeOrder) Buy(ctx context.Context, client spanner.Client) error {
*snip*
// Update seller's account balance
       lister.UpdateBalance(ctx, txn, o.ListPrice)
*snip*
}

// models/players.go
// Update a player's balance, and add an entry into the player ledger
func (p *Player) UpdateBalance(ctx context.Context, txn *spanner.ReadWriteTransaction, newAmount big.Rat) error {
   // This modifies player's AccountBalance, which is used to update the player entry
   p.AccountBalance.Add(&p.AccountBalance, &newAmount)

   txn.BufferWrite([]*spanner.Mutation{
       spanner.Update("players", []string{"playerUUID", "account_balance"}, []interface{}{p.PlayerUUID, p.AccountBalance}),
       spanner.Insert("player_ledger_entries", []string{"playerUUID", "amount", "game_session", "source", "entryDate"},
           []interface{}{p.PlayerUUID, newAmount, p.CurrentGame, "tradepost", spanner.CommitTimestamp}),
   })
   return nil
}
  • 从买方的账号余额中减去订单的 list_price,并添加相应的账簿条目。
 // Update buyer's account balance
       negAmount := o.ListPrice.Neg(&o.ListPrice)
       buyer.UpdateBalance(ctx, txn, *negAmount)
  • 将 player_item 移至新玩家,方法是将包含游戏和买家详细信息的游戏商品的新实例插入到 PlayerItems 表中,然后移除卖家的商品实例。
// models/player_items.go
// Move an item to a new player, removes the item entry from the old player
func (pi *PlayerItem) MoveItem(ctx context.Context, txn *spanner.ReadWriteTransaction, toPlayer string) error {
   fmt.Printf("Buyer: %s", toPlayer)
   txn.BufferWrite([]*spanner.Mutation{
       spanner.Insert("player_items", []string{"playerItemUUID", "playerUUID", "itemUUID", "price", "source", "game_session"},
           []interface{}{pi.PlayerItemUUID, toPlayer, pi.ItemUUID, pi.Price, pi.Source, pi.GameSession}),
       spanner.Delete("player_items", spanner.Key{pi.PlayerUUID, pi.PlayerItemUUID}),
   })

   return nil
}
  • 更新订单条目,以表明相应商品已发货,不再处于有效状态。

总而言之,购买函数如下所示:

 // Buy an order
func (o *TradeOrder) Buy(ctx context.Context, client spanner.Client) error {
   // Fulfil the order
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       // Get Order information
       err := o.getOrderDetails(ctx, txn)
       if err != nil {
           return err
       }

       // Validate order can be filled
       if !validatePurchase(*o) {
           errorMsg := fmt.Sprintf("Order (%s) cannot be filled.", o.OrderUUID)
           return errors.New(errorMsg)
       }

       // Validate buyer has the money
       buyer := Player{PlayerUUID: o.Buyer}
       err = buyer.GetBalance(ctx, txn)
       if err != nil {
           return err
       }

       if !validateBuyer(buyer, *o) {
           errorMsg := fmt.Sprintf("Buyer (%s) cannot purchase order (%s).", buyer.PlayerUUID, o.OrderUUID)
           return errors.New(errorMsg)
       }

       // Move money from buyer to seller (which includes ledger entries)
       var m []*spanner.Mutation
       lister := Player{PlayerUUID: o.Lister}
       err = lister.GetBalance(ctx, txn)
       if err != nil {
           return err
       }

       // Update seller's account balance
       lister.UpdateBalance(ctx, txn, o.ListPrice)

       // Update buyer's account balance
       negAmount := o.ListPrice.Neg(&o.ListPrice)
       buyer.UpdateBalance(ctx, txn, *negAmount)

       // Move item from seller to buyer, mark item as visible.
       pi, err := GetPlayerItem(ctx, txn, o.Lister, o.PlayerItemUUID)
       if err != nil {
           return err
       }
       pi.GameSession = buyer.CurrentGame

       // Moves the item from lister (current pi.PlayerUUID) to buyer
       pi.MoveItem(ctx, txn, o.Buyer)

       // Update order information
       cols := []string{"orderUUID", "active", "filled", "buyer", "ended"}
       m = append(m, spanner.Update("trade_orders", cols, []interface{}{o.OrderUUID, false, true, o.Buyer, time.Now()}))

       txn.BufferWrite(m)
       return nil
   })

   if err != nil {
       return err
   }

   // return empty error on success
   return nil
}

默认情况下,服务是使用环境变量配置的。请参阅 ./src/golang/tradepost-service/config/config.go 文件的相关部分。

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

   // 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:8083 上运行服务,以避免与其他服务发生冲突*。*

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

运行服务

运行该服务将建立在端口 8083 上运行的服务。此服务与 item-service 有许多相同的依赖项,因此不会下载新的依赖项。

cd ~/spanner-gaming-sample/src/golang/tradepost-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] GET    /trades/player_items      --> main.getPlayerItem (4 handlers)
[GIN-debug] POST   /trades/sell              --> main.createOrder (4 handlers)
[GIN-debug] GET    /trades/open              --> main.getOpenOrder (4 handlers)
[GIN-debug] PUT    /trades/buy               --> main.purchaseOrder (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8083

发布内容

通过发出 GET 请求来检索要出售的 PlayerItem,测试服务:

curl http://localhost:8083/trades/player_items

命令输出

{
    "PlayerUUID": "b74cc194-87b0-4a55-a67f-0f0742ef6352",
    "PlayerItemUUID": "a42b1899-4509-4fce-9958-265d2a2838a0",
    "Price": "3.14"
}

现在,我们来调用 /trades/sell 端点发布待售商品

curl http://localhost:8083/trades/sell \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"lister": "<PlayerUUID>","playerItemUUID": "<PlayerItemUUID>", "list_price": "<some price higher than item's price>"}'

命令输出

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <Date> 
Content-Length: 38

"282ea691-b956-4c4c-95ff-f461d6415651"

摘要

在此步骤中,您部署了 tradepost-service 以处理创建卖单。此服务还负责处理购买这些订单的功能。

后续步骤

现在,您的服务已开始运行,接下来可以模拟玩家在交易站上买卖商品了!

5. 开始交易

现在,商品和交易站服务已在运行,您可以使用提供的 Locust 生成器来生成负载。

Locust 提供了一个用于运行生成器的 Web 界面,但在此实验中,您将使用命令行(–headless 选项)。

生成游戏物品

首先,您需要生成商品。./generators/item_generator.py 文件包含一项任务,用于创建名称为随机字符串且价格值为随机值的游戏商品:

# Generate random items
class ItemLoad(HttpUser):
   def generateItemName(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

   def generateItemValue(self):
       return str(decimal.Decimal(random.randrange(100, 10000))/100)

   @task
   def createItem(self):
       headers = {"Content-Type": "application/json"}
       data = {"item_name": self.generateItemName(), "item_value": self.generateItemValue()}

       self.client.post("/items", data=json.dumps(data), headers=headers)

以下命令会调用 item_generator.py 文件,该文件将生成 10 秒的游戏物品 (t=10s)

cd ~/spanner-gaming-sample
locust -H http://127.0.0.1:8082 -f ./generators/item_generator.py --headless -u=1 -r=1 -t=10s

命令输出

*snip*
/INFO/locust.main: --run-time limit reached. Stopping Locust
/INFO/locust.main: Shutting down (exit code 0)
 Name                                                                              # reqs      # fails  |     Avg     Min     Max  Median  |   req/s failures/s
----------------------------------------------------------------------------------------------------------------------------------------------------------------
 POST /items                                                                          606     0(0.00%)  |      16      12     161      15  |   60.61    0.00
----------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregated                                                                           606     0(0.00%)  |      16      12     161      15  |   60.61    0.00

Response time percentiles (approximated)
 Type     Name                                                                                  50%    66%    75%    80%    90%    95%    98%    99%  99.9% 99.99%   100% # reqs
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 POST     /items                                                                                 15     16     16     17     18     19     21     34    160    160    160    606
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 None     Aggregated                                                                             15     16     16     17     18     19     21     34    160    160    160    606

玩家获得物品和金钱

接下来,我们让玩家获取物品和金钱,以便他们可以在交易站上参与交易。为此,./generators/game_server.py 文件提供了用于检索要分配给玩家的游戏物品以及随机数量的币的任务。

 # Players generate items and money at 5:2 ratio. We don't want to devalue the currency!
class GameLoad(HttpUser):
   def on_start(self):
       self.getItems()

   def getItems(self):
       headers = {"Content-Type": "application/json"}
       r = requests.get(f"{self.host}/items", headers=headers)

       global itemUUIDs
       itemUUIDs = json.loads(r.text)

   def generateAmount(self):
       return str(round(random.uniform(1.01, 49.99), 2))

   @task(2)
   def acquireMoney(self):
       headers = {"Content-Type": "application/json"}

       # Get a random player that's part of a game, and update balance
       with self.client.get("/players", headers=headers, catch_response=True) as response:
           try:
               data = {"playerUUID": response.json()["playerUUID"], "amount": self.generateAmount(), "source": "loot"}
               self.client.put("/players/balance", data=json.dumps(data), headers=headers)
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'playerUUID'")

   @task(5)
   def acquireItem(self):
       headers = {"Content-Type": "application/json"}

       # Get a random player that's part of a game, and add an item
       with self.client.get("/players", headers=headers, catch_response=True) as response:
           try:
               itemUUID = itemUUIDs[random.randint(0, len(itemUUIDs)-1)]
               data = {"playerUUID": response.json()["playerUUID"], "itemUUID": itemUUID, "source": "loot"}
               self.client.post("/players/items", data=json.dumps(data), headers=headers)
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'playerUUID'")

此命令可让玩家在 60 秒内获得物品和金钱:

locust -H http://127.0.0.1:8082 -f game_server.py --headless -u=1 -r=1 -t=60s

命令输出

*snip*
dev-machine/INFO/locust.main: --run-time limit reached. Stopping Locust
dev-machine/INFO/locust.main: Shutting down (exit code 0)
 Name                                                                              # reqs      # fails  |     Avg     Min     Max  Median  |   req/s failures/s
----------------------------------------------------------------------------------------------------------------------------------------------------------------
 GET /players                                                                         231     0(0.00%)  |      14       9      30      13  |   23.16    0.00
 PUT /players/balance                                                                  53     0(0.00%)  |      33      30      39      34  |    5.31    0.00
 POST /players/items                                                                  178     0(0.00%)  |      26      22      75      26  |   17.85    0.00
----------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregated                                                                           462     0(0.00%)  |      21       9      75      23  |   46.32    0.00

Response time percentiles (approximated)
 Type     Name                                                                                  50%    66%    75%    80%    90%    95%    98%    99%  99.9% 99.99%   100% # reqs
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 GET      /players                                                                               13     16     17     17     19     20     21     23     30     30     30    231
 PUT      /players/balance                                                                       34     34     35     35     36     37     38     40     40     40     40     53
 POST     /players/items                                                                         26     27     27     27     28     29     34     53     76     76     76    178
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 None     Aggregated                                                                             23     26     27     27     32     34     36     37     76     76     76    462

在交易站买卖商品的玩家

现在,玩家已经拥有商品和购买商品的资金,可以开始使用交易站了!

./generators/trading_server.py 生成器文件提供了用于创建卖出订单和履行这些订单的任务。

 # Players can sell and buy items
class TradeLoad(HttpUser):
   def itemMarkup(self, value):
       f = float(value)
       return str(f*1.5)

   @task
   def sellItem(self):
       headers = {"Content-Type": "application/json"}

       # Get a random item
       with self.client.get("/trades/player_items", headers=headers, catch_response=True) as response:
           try:
               playerUUID = response.json()["PlayerUUID"]
               playerItemUUID = response.json()["PlayerItemUUID"]
               list_price = self.itemMarkup(response.json()["Price"])

  # Currently don't have any items that can be sold, retry
               if playerItemUUID == "":
                   raise RescheduleTask()


               data = {"lister": playerUUID, "playerItemUUID": playerItemUUID, "list_price": list_price}
               self.client.post("/trades/sell", data=json.dumps(data), headers=headers)
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'playerUUID'")

   @task
   def buyItem(self):
       headers = {"Content-Type": "application/json"}

       # Get a random item
       with self.client.get("/trades/open", headers=headers, catch_response=True) as response:
           try:
               orderUUID = response.json()["OrderUUID"]
               buyerUUID = response.json()["BuyerUUID"]

                    # Currently don't have any buyers that can fill the order, retry
               if buyerUUID == "":
                   raise RescheduleTask()

               data = {"orderUUID": orderUUID, "buyer": buyerUUID}
               self.client.put("/trades/buy", data=json.dumps(data), headers=headers)
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'playerUUID'")

此命令将允许玩家列出他们获得的待售商品,并允许其他玩家在 10 秒内购买这些商品:

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

命令输出

*snip*
 Name                                                                              # reqs      # fails  |     Avg     Min     Max  Median  |   req/s failures/s
----------------------------------------------------------------------------------------------------------------------------------------------------------------
 PUT /trades/buy                                                                       20    5(25.00%)  |      43      10      78      43  |    2.07    0.52
 GET /trades/open                                                                      20     0(0.00%)  |     358       7     971     350  |    2.07    0.00
 GET /trades/player_items                                                              20     0(0.00%)  |      49      35     113      41  |    2.07    0.00
 POST /trades/sell                                                                     20     0(0.00%)  |      29      21     110      24  |    2.07    0.00
----------------------------------------------------------------------------------------------------------------------------------------------------------------
 Aggregated                                                                            80     5(6.25%)  |     120       7     971      42  |    8.29    0.52

Response time percentiles (approximated)
 Type     Name                                                                                  50%    66%    75%    80%    90%    95%    98%    99%  99.9% 99.99%   100% # reqs
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 PUT      /trades/buy                                                                            43     45     49     50     71     78     78     78     78     78     78     20
 GET      /trades/open                                                                          360    500    540    550    640    970    970    970    970    970    970     20
 GET      /trades/player_items                                                                   43     55     57     59     72    110    110    110    110    110    110     20
 POST     /trades/sell                                                                           24     25     25     27     50    110    110    110    110    110    110     20
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
 None     Aggregated                                                                             42     50     71    110    440    550    640    970    970    970    970     80

摘要

在此步骤中,您模拟了玩家注册玩游戏,然后模拟了玩家使用配对服务玩游戏。这些模拟利用 Locust Python 框架向我们服务的 REST API 发出请求。

您可以随意修改创建玩家和玩游戏所花费的时间,以及并发用户数 (-u)

后续步骤

模拟结束后,您需要通过查询 Spanner 来检查各种统计信息。

6. 检索交易统计信息

现在,我们已经模拟了玩家获取金钱和物品,然后在交易站出售这些物品的过程,接下来我们来查看一些统计信息。

为此,请使用 Cloud 控制台向 Spanner 发出查询请求。

b5e3154c6f7cb0cf.png

检查未完成的交易订单与已完成的交易订单

在交易站购买 TradeOrder 后,系统会更新 filled 元数据字段。

通过此查询,您可以查看有多少订单处于未完成状态,以及有多少订单已完成:

-- Open vs Filled Orders
SELECT Type, NumTrades FROM
(SELECT "Open Trades" as Type, count(*) as NumTrades FROM trade_orders WHERE active=true
UNION ALL
SELECT "Filled Trades" as Type, count(*) as NumTrades FROM trade_orders WHERE filled=true
)

结果:

类型

NumTrades

未完成的交易

159

已成交的交易

454

检查玩家账号余额和商品数量

如果玩家的 current_game 列已设置,则表示该玩家正在玩游戏。否则,他们目前未在玩游戏。

若要获取当前正在玩游戏且拥有最多商品的玩家(包括其 account_balance),请执行以下操作

,请使用以下查询:

SELECT playerUUID, account_balance, (SELECT COUNT(*) FROM player_items WHERE playerUUID=p.PlayerUUID) AS numItems, current_game
FROM players AS p
WHERE current_game IS NOT NULL
ORDER BY numItems DESC
LIMIT 10;

结果:

playerUUID

account_balance

numItems

current_game

04d14288-a7c3-4515-9296-44eeae184d6d

1396.55

201

79f24cd4-102d-4d7d-bbe8-be69d364ebbf

03836948-d591-4967-a8a8-80506454916d

2005.085

192

053c353e-e56d-4b2e-9431-76eedf58bea5


snip


snip


snip


snip

摘要

在此步骤中,您使用 Cloud 控制台查询 Spanner,查看了玩家和交易订单的各种统计信息。

后续步骤

接下来,该清理了!

7. 清理

在玩完 Spanner 后,我们需要清理游乐场。幸运的是,此步骤非常简单,只需前往 Cloud Console 的 Cloud Spanner 部分,然后删除为此 Codelab 创建的实例即可。

8. 恭喜!

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

后续操作

在本实验中,您已完成设置两项服务,以处理游戏物品生成和玩家获取要在交易站出售的物品。

这些代码示例应有助于您更好地了解 Cloud Spanner 在事务中如何实现 DML 和 Spanner 突变的一致性。

您可以随意使用提供的生成器来探索如何扩缩 Spanner。