1. 简介
Cloud Spanner 是一项可横向扩容的全球分布式全代管式关系型数据库服务,可提供 ACID 事务和 SQL 语义,同时不会影响性能和高可用性。
这些功能使 Spanner 非常适合希望支持全球玩家群体或注重数据一致性的游戏架构。
在本实验中,您将创建两个 Go 服务,它们与区域级 Spanner 数据库进行交互,使玩家能够获得商品和金钱 (item-service
),然后在交易站内列出商品供其他玩家购买 (tradepost-service
)。
此实验依赖于 Cloud Spanner 游戏开发入门 Codelab,使用 profile-service
和 matchmaking-service
生成了玩家和游戏。
接下来,您将利用 Python 加载框架 Locust.io 生成数据,以模拟玩家在“玩游戏”过程中获取金钱和物品。然后,玩家可以在交易站上展示待售商品,这样其他玩家只要有足够的钱就能购买这些商品。
您还需要查询 Spanner 以确定玩家账号余额和商品数量,以及未结或已成交的交易订单的一些统计信息。
最后,您将清理在本实验中创建的资源。
构建内容
在本实验中,您将:
- 重复使用 Cloud Spanner 游戏开发入门中的 Spanner 实例。
- 部署使用 Go 编写的物品服务来处理玩家获取物品和金钱的问题
- 部署使用 Go 编写的 Trading Post 服务,以模拟玩家列出待售商品以及购买这些商品的其他玩家。
学习内容
- 如何使用读写事务来确保数据更改的一致性
- 如何利用 DML 和 Spanner 变更来修改数据
所需条件
- 与结算账号关联的 Google Cloud 项目。
- 网络浏览器,例如 Chrome 或 Firefox。
- 之前已完成 Cloud Spanner 游戏开发入门(不含清理步骤)。
2. 设置和要求
完成 Cloud Spanner 游戏开发入门 Codelab
完成 Cloud Spanner 游戏开发入门 Codelab。这是获取玩家和游戏数据集所必需的。玩家和游戏需要获得物品和金钱,而这些物品和金钱用来上架待售物品,以及从交易站购买商品。
在 Cloud Shell 中配置环境变量
点击“激活 Cloud Shell”图标 ,打开 Cloud Shell(配置和连接到环境应该只需要片刻时间,因为您之前已经执行过此操作)。
在连接到 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_items
、player_items
、player_ledger_entries
和 trade_orders
。
玩家物品关系
交易订单关系
游戏物品会添加到 game_items
表中,然后玩家可以获取。player_items
表同时具有 itemUUID 和 playerUUID 的外键,以确保玩家仅获取有效的项。
player_ledger_entries
表会跟踪玩家账号余额的所有货币变化。这可能包括通过战利品获得收入,或通过在交易站销售物品来获得金钱。
最后,trade_orders
表用于处理销售订单的发布以及买方履行这些订单的工作。
如需创建架构,请点击 Cloud 控制台中的 Write DDL
按钮:
在这里,您需要输入 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
”按钮修改架构,然后等待架构更新完成:
后续步骤
接下来,您将部署商品服务。
3. 部署商品服务
服务概览
商品服务是使用 Go 编写的 REST API,它利用了 gin 框架。在此 API 中,参与开放式游戏的玩家会获得金钱和物品。
./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、PlayerLedger 和 PlayerItem 搭配使用,具体定义如下:
// 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_ledger_entries 和 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"
接下来,您需要让玩家获得该商品。为此,您需要一个 ItemUUID 和 PlayerUUID。ItemUUID 是上一个命令的输出。在此示例中为 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 服务
服务概览
Tradepost 服务是使用 Go 编写的 REST API,利用了 gin 框架。在此 API 中,玩家物品会进行出售。然后,游戏玩家可以进行公开交易,如果他们有足够的钱,也可以购买商品。
Tradepost 服务的 ./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、Player 和 PlayerLedger 结构体的所需结构体:
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 的 playerItemUUID、lister 和 list_price.
选择 Spanner 变更以创建交易订单,并将 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 请求后即可进行购买。所需信息为 orderUUID 和买方(即进行购买交易的玩家的 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 添加到 lister 的账号余额中,并提供匹配的分类账条目。
// 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)
- 将包含游戏和买方详情的游戏商品的新实例插入到 PlayerItems 表中,从而将 player_item 移动到新玩家,然后移除该列表项的 lister 实例。
// 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 函数如下所示:
// 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. 开始交易
现在商品和 tradepost 服务已处于运行状态,您可以使用所提供的 locust 生成器生成负载。
Locust 提供了一个用于运行生成器的网页界面,但在本实验中,您将使用命令行(–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)
以下命令调用生成时长为 10 秒 (t=10s) 的游戏物品的 item_generator.py 文件:
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 框架向 Google 服务REST API。
您可以随意修改创建玩家和玩游戏所用的时间,以及并发用户数量 (-u)。
后续步骤
模拟结束后,您需要通过查询 Spanner 来查看各种统计信息。
6. 检索交易统计信息
现在,我们已模拟玩家获得金钱和物品,然后在交易站上销售这些物品,我们来看看一些统计信息。
为此,请使用 Cloud 控制台向 Spanner 发出查询请求。
查看待完成和已履行的交易订单
用户通过交易站购买 TradeOrder 后,TradeOrder的元数据字段会更新。
下面的查询可用于查看有多少订单处于待付状态和已履行的订单数量:
-- 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 计算目前玩游戏道具最多的前 10 位玩家
,,请使用以下查询:
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;
结果:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
摘要
在此步骤中,您使用 Cloud 控制台查询 Spanner,查看了玩家和交易订单的各种统计信息。
后续步骤
接下来该清理了!
7. 正在清理
在玩过 Spanner 的各种乐趣之后,我们需要清理一下游乐场。幸运的是,这一步很简单,只需进入 Cloud 控制台的 Cloud Spanner 部分,然后删除我们为此 Codelab 创建的实例即可。
8. 恭喜!
恭喜,您已成功在 Spanner 上部署示例游戏
后续操作
在本实验中,您已经完成了两项服务设置,分别用于处理游戏商品生成和玩家获取待售商品。
这些代码示例应该可以帮助您更好地了解 Cloud Spanner 在事务中的一致性如何应用于 DML 和 Spanner 变更。
您可以随意使用所提供的生成器来探索 Spanner。