1. Introducción
Cloud Spanner es un servicio de bases de datos relacionales completamente administrado, distribuido a nivel global y escalable horizontalmente que proporciona transacciones ACID y semántica de SQL sin renunciar al rendimiento y la alta disponibilidad.
Estas funciones hacen que Spanner sea una excelente opción para la arquitectura de los juegos que desean habilitar una base de jugadores global o que se preocupan por la coherencia de los datos.
En este lab, crearás dos servicios de Go que interactúan con una base de datos regional de Spanner para permitir que los jugadores adquieran elementos y dinero (item-service) y, luego, publiquen elementos en el mercado para que otros jugadores los compren (tradepost-service).
Este lab depende del codelab Cloud Spanner Getting Started with Games Development para generar jugadores y juegos con profile-service y matchmaking-service.

A continuación, generarás datos aprovechando el framework de carga de Python Locust.io para simular que los jugadores adquieren dinero y elementos durante el "juego". Luego, los jugadores pueden poner a la venta artículos en un puesto comercial, donde otros jugadores con suficiente dinero pueden comprarlos.
También consultarás Spanner para determinar los saldos de las cuentas de los jugadores y la cantidad de artículos, y algunas estadísticas sobre las órdenes de intercambio que están abiertas o se completaron.
Por último, limpiarás los recursos que se crearon en este lab.
Qué compilarás
Como parte de este lab, harás lo siguiente:
- Vuelve a usar la instancia de Spanner de Comienza a usar Cloud Spanner para el desarrollo de juegos.
- Implementa un servicio de elementos escrito en Go para controlar la adquisición de elementos y dinero por parte de los jugadores
- Implementa un servicio de Trading Post escrito en Go para simular que los jugadores publican artículos a la venta y que otros jugadores compran esos artículos.
Qué aprenderás
- Cómo usar transacciones de lectura y escritura para garantizar la coherencia de los cambios en los datos
- Cómo aprovechar las mutaciones de DML y Spanner para modificar datos
Requisitos
- Es un proyecto de Google Cloud que está conectado a una cuenta de facturación.
- Un navegador, como Chrome o Firefox.
- Haber completado anteriormente Cloud Spanner Getting Started with Games Development, sin el paso de limpieza
2. Configuración y requisitos
Completa el codelab de Cloud Spanner Getting Started with Games Development
Completa el codelab Cloud Spanner Getting Started with Games Development. Este parámetro es obligatorio para obtener un conjunto de datos de jugadores y juegos. Se necesitan jugadores y juegos para adquirir elementos y dinero, que a su vez se usan para publicar elementos a la venta y comprar elementos en el puesto de intercambio.
Configurar variables de entorno en Cloud Shell
Para abrir Cloud Shell, haz clic en Activar Cloud Shell
(el aprovisionamiento y la conexión al entorno solo deberían llevar unos minutos, ya que lo hiciste antes).


Una vez que te conectes a Cloud Shell, deberías ver que ya te autenticaste y que el proyecto ya se configuró con tu PROJECT_ID.
Configura las variables de entorno de SPANNER en Cloud Shell
export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
Crea el esquema
Ahora que se creó tu base de datos, puedes definir el esquema en la base de datos sample-game.
En este lab, se crearán cuatro tablas nuevas: game_items, player_items, player_ledger_entries y trade_orders.

Relaciones entre elementos del reproductor

Relaciones de pedidos comerciales
Los elementos del juego se agregan en la tabla game_items y, luego, los jugadores pueden adquirirlos. La tabla player_items tiene claves externas para un itemUUID y un playerUUID para garantizar que los jugadores solo adquieran elementos válidos.
La tabla player_ledger_entries hace un seguimiento de los cambios monetarios en el saldo de la cuenta del jugador. Esto puede ser adquirir dinero del botín o vender artículos en el puesto comercial.
Por último, la tabla trade_orders se usa para controlar la publicación de órdenes de venta y para que los compradores cumplan con esas órdenes.
Para crear el esquema, haz clic en el botón Write DDL en la consola de Cloud:

Aquí, ingresarás la definición del esquema del archivo 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);
Haz clic en el botón “Submit” para modificar el esquema y espera a que se complete la actualización:

Cuál es el próximo paso
A continuación, implementarás el servicio de elementos.
3. Implementa el servicio de elementos
Descripción general del servicio
El servicio de elementos es una API de REST escrita en Go que aprovecha el framework de Gin. En esta API, los jugadores que participan en juegos abiertos adquieren dinero y elementos.

El archivo ./src/golang/item-service/main.go configura los siguientes extremos para que funcionen con los elementos del juego y los jugadores que adquieren esos elementos. Además, hay un extremo para que los jugadores adquieran dinero.
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())
}
La configuración y el uso de las conexiones de Spanner se manejan exactamente igual que los servicios profile-service y matchmaking-service del codelab anterior.
El servicio de elementos funciona con GameItem, Player, PlayerLedger y PlayerItem con las siguientes definiciones:
// 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"`
}
Primero, el juego debe tener algunos elementos creados. Para ello, se llama a una solicitud POST al extremo /items. Esta es una inserción de DML muy simple en la tabla 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
}
Para adquirir un elemento, se llama a una solicitud POST al extremo /players/items. La lógica de este endpoint es recuperar el valor actual de un elemento del juego y la sesión de juego actual del jugador. Luego, inserta la información adecuada en la tabla player_items para indicar la fuente y la hora de adquisición del elemento.
Esto se asigna a las siguientes funciones:
// 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
}
Para que un jugador adquiera dinero, se llama a una solicitud PUT al extremo /players/updatebalance.
La lógica de este extremo es actualizar el saldo del jugador después de aplicar el importe, así como actualizar la tabla player_ledger_entries con un registro de la adquisición. El saldo de la cuenta del jugador se modifica para devolverse al llamador. El DML se usa para modificar tanto los jugadores como los registros de player_ledger_entries.
Esto se asigna a las siguientes funciones:
// 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
}
De forma predeterminada, el servicio se configura con variables de entorno. Consulta la sección pertinente del archivo ./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
}
Puedes ver que el comportamiento predeterminado es ejecutar el servicio en localhost:8082.
Con esta información, es hora de ejecutar el servicio.
Ejecuta el servicio
Cuando ejecutes el servicio, se descargarán las dependencias y se establecerá el servicio en ejecución en el puerto 8082:
cd ~/spanner-gaming-sample/src/golang/item-service
go run . &
Resultado del comando:
[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
Para probar el servicio, ejecuta un comando curl para crear un elemento:
curl http://localhost:8082/items \
--include \
--header "Content-Type: application/json" \
--request "POST" \
--data '{"item_name": "test_item","item_value": "3.14"}'
Resultado del comando:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <Date>
Content-Length: 38
"aecde380-0a79-48c0-ab5d-0da675d3412c"
A continuación, querrás que un jugador adquiera este elemento. Para ello, necesitas un ItemUUID y un PlayerUUID. El ItemUUID es el resultado del comando anterior. En este ejemplo, es aecde380-0a79-48c0-ab5d-0da675d3412c.
Para obtener un PlayerUUID, llama al extremo GET /players:
curl http://localhost:8082/players
Resultado del comando:
{
"playerUUID": "b74cc194-87b0-4a55-a67f-0f0742ef6352",
"updated": "0001-01-01T00:00:00Z",
"account_balance": {},
"current_game": "7b97fa85-5658-4ded-a962-4c09269a0a79"
}
Para que el jugador adquiera el elemento, realiza una solicitud al extremo 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"}'
Resultado del comando:
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
}
Resumen
En este paso, implementaste el servicio de elementos que permite crear elementos del juego y que los jugadores asignados a juegos abiertos puedan adquirir dinero y elementos del juego.
Próximos pasos
En el siguiente paso, implementarás el servicio de la oficina de comercio.
4. Implementa el servicio de mercado
Descripción general del servicio
El servicio de mercado es una API de REST escrita en Go que aprovecha el framework de Gin. En esta API, los elementos del jugador se publican para venderse. Luego, los jugadores pueden obtener intercambios abiertos y, si tienen suficiente dinero, comprar el elemento.

El archivo ./src/golang/tradepost-service/main.go para el servicio de tradepost sigue una configuración y un código similares a los de los otros servicios, por lo que no se repite aquí. Este servicio expone varios extremos de la siguiente manera:
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())
}
Este servicio proporciona una estructura TradeOrder, así como las estructuras requeridas para GameItem, PlayerItem, Player y 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"`
}
Para crear una orden de compraventa, se envía una solicitud POST al extremo de la API /trades/sell. La información obligatoria es el playerItemUUID del player_item que se venderá, el lister y el list_price.
Se eligen las mutaciones de Spanner para crear la orden comercial y marcar el elemento del jugador como no visible. Esto evita que el vendedor publique artículos duplicados para la venta.
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
}
Antes de crear el pedido, se valida el objeto PlayerItem para garantizar que se pueda poner a la venta. Principalmente, esto significa que el PlayerItem es visible para el jugador y que no venció.
// 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
}
Para realizar una compra, se debe enviar una solicitud PUT al extremo /trades/buy. La información obligatoria es orderUUID y buyer, que es el UUID del jugador que realiza la compra.
Debido a esta complejidad y a la cantidad de cambios, se vuelven a elegir mutaciones para comprar el pedido. Las siguientes operaciones se realizan en una sola transacción de lectura y escritura:
- Valida que se pueda completar el pedido porque no se completó anteriormente y no venció.
// 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
}
- Recupera la información del comprador y valida que pueda comprar el artículo. Esto significa que el comprador no puede ser el mismo que el vendedor y debe tener suficiente dinero.
// 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
}
- Agrega el list_price del pedido al saldo de la cuenta del lister, con una entrada de libro mayor coincidente.
// 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
}
- Se resta el list_price del pedido del saldo de la cuenta del comprador, con una entrada de libro mayor coincidente.
// Update buyer's account balance
negAmount := o.ListPrice.Neg(&o.ListPrice)
buyer.UpdateBalance(ctx, txn, *negAmount)
- Mueve el elemento del jugador al jugador nuevo insertando una instancia nueva del elemento del juego con los detalles del juego y del comprador en la tabla PlayerItems y quita la instancia del elemento del vendedor.
// 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
}
- Actualiza la entrada de Pedidos para indicar que el artículo se completó y ya no está activo.
En conjunto, la función Buy se ve de la siguiente manera:
// 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
}
De forma predeterminada, el servicio se configura con variables de entorno. Consulta la sección pertinente del archivo ./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
}
Puedes ver que el comportamiento predeterminado es ejecutar el servicio en localhost:8083 para evitar conflictos con otros servicios.*.*
Con esta información, es hora de ejecutar el servicio de la estación comercial.
Ejecuta el servicio
Ejecutar el servicio establecerá el servicio en ejecución en el puerto 8083. Este servicio tiene muchas de las mismas dependencias que el servicio de elementos, por lo que no se descargarán dependencias nuevas.
cd ~/spanner-gaming-sample/src/golang/tradepost-service
go run . &
Resultado del comando:
[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
Publicar un elemento
Para probar el servicio, envía una solicitud GET para recuperar un PlayerItem para vender:
curl http://localhost:8083/trades/player_items
Resultado del comando:
{
"PlayerUUID": "b74cc194-87b0-4a55-a67f-0f0742ef6352",
"PlayerItemUUID": "a42b1899-4509-4fce-9958-265d2a2838a0",
"Price": "3.14"
}
Ahora, publiquemos un artículo para la venta llamando al extremo /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>"}'
Resultado del comando:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <Date>
Content-Length: 38
"282ea691-b956-4c4c-95ff-f461d6415651"
Resumen
En este paso, implementaste el servicio tradepost-service para controlar la creación de órdenes de venta. Este servicio también controla la capacidad de comprar esos pedidos.
Próximos pasos
Ahora que tus servicios están en ejecución, es momento de simular a los jugadores que venden y compran en el puesto de intercambio.
5. Comienza a operar
Ahora que los servicios de elementos y puestos comerciales están en funcionamiento, puedes generar carga con los generadores de Locust proporcionados.
Locust ofrece una interfaz web para ejecutar los generadores, pero en este lab usarás la línea de comandos (opción –headless).
Generar elementos del juego
Primero, deberás generar elementos. El archivo ./generators/item_generator.py incluye una tarea para crear elementos del juego con cadenas aleatorias para los nombres y valores de precios aleatorios:
# 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)
El siguiente comando llama al archivo item_generator.py que generará elementos del juego durante 10 segundos (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
Resultado del comando:
*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
Los jugadores adquieren elementos y dinero.
A continuación, haremos que los jugadores adquieran elementos y dinero para que puedan participar en el puesto de intercambio. Para ello, el archivo ./generators/game_server.py proporciona tareas para recuperar elementos del juego y asignarlos a los jugadores, así como cantidades aleatorias de moneda.
# 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'")
Este comando permitirá que los jugadores adquieran elementos y dinero durante 60 segundos:
locust -H http://127.0.0.1:8082 -f game_server.py --headless -u=1 -r=1 -t=60s
Resultado del comando:
*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
Jugadores que compran y venden en el mercado
Ahora que los jugadores tienen artículos y dinero para comprarlos, pueden comenzar a usar el puesto de intercambio.
El archivo del generador ./generators/trading_server.py proporciona tareas para crear órdenes de venta y completarlas.
# 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'")
Este comando permitirá que los jugadores pongan a la venta los elementos que adquirieron y que otros jugadores compren esos elementos durante 10 segundos:
locust -H http://127.0.0.1:8083 -f ./generators/trading_server.py --headless -u=1 -r=1 -t=10s
Resultado del comando:
*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
Resumen
En este paso, simulaste que los jugadores se registraban para jugar y, luego, ejecutaste simulaciones para que los jugadores jugaran con el servicio de matchmaking. En estas simulaciones, se aprovechó el framework de Locust Python para emitir solicitudes a la API de REST de nuestros servicios.
Puedes modificar el tiempo dedicado a crear jugadores y jugar, así como la cantidad de usuarios simultáneos (-u).
Próximos pasos
Después de la simulación, querrás consultar varias estadísticas con Spanner.
6. Recupera estadísticas de comercio
Ahora que simulamos que los jugadores adquieren dinero y elementos, y luego venden esos elementos en el puesto de intercambio, verifiquemos algunas estadísticas.
Para ello, usa Cloud Console para enviar solicitudes de consulta a Spanner.

Cómo verificar las órdenes de compra abiertas y completadas
Cuando se compra un TradeOrder en el mercado, se actualiza el campo de metadatos filled.
Esta consulta te permitirá verificar cuántos pedidos están abiertos y cuántos se completaron:
-- 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
)
Resultado:
Tipo | NumTrades |
Operaciones abiertas | 159 |
Operaciones completadas | 454 |
Verificar el saldo de la cuenta del jugador y la cantidad de elementos
Un jugador está jugando si se establece su columna current_game. De lo contrario, no está jugando.
Para obtener los 10 mejores jugadores que actualmente juegan con la mayor cantidad de elementos, con su account_balance
, usa esta búsqueda :
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;
Resultado:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Resumen
En este paso, revisaste varias estadísticas de los pedidos de jugadores y de intercambios con la consola de Cloud para consultar Spanner.
Próximos pasos
A continuación, es hora de limpiar.
7. Realiza una limpieza
Después de la diversión cuando se juega con Spanner, debemos limpiar nuestra zona de pruebas. Por suerte, este paso es sencillo. Ve a la sección de Cloud Spanner de la consola de Cloud y borra la instancia que creamos para este codelab.
8. ¡Felicitaciones!
Felicitaciones. Implementaste correctamente un juego de ejemplo en Spanner.
Próximos pasos
En este lab, completaste la configuración de dos servicios para controlar la generación de elementos del juego y la adquisición de elementos por parte de los jugadores para venderlos en la publicación comercial.
Estos ejemplos de código deberían ayudarte a comprender mejor cómo funciona la coherencia de Cloud Spanner dentro de las transacciones para las mutaciones de DML y Spanner.
Puedes usar los generadores proporcionados para explorar el escalamiento de Spanner.