1. Introducción
Cloud Spanner es un servicio de base de datos relacional completamente administrado, escalable horizontalmente y distribuido a nivel global que proporciona transacciones ACID y semántica de SQL sin renunciar al rendimiento ni a la alta disponibilidad.
Estas funciones hacen que Spanner sea ideal para la arquitectura de videojuegos que desean habilitar una base de jugadores global o les preocupa la coherencia de los datos.
En este lab, crearás dos servicios Go que interactúen con una base de datos regional de Spanner para permitir que los jugadores adquieran elementos y dinero (item-service
) y, luego, enumerarás elementos en el puesto de intercambio para que otros jugadores los compren (tradepost-service
).
Este lab depende del codelab Cómo comenzar a usar el desarrollo de juegos de Cloud Spanner 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 transcurso del juego. Los jugadores pueden publicar artículos para la venta en un puesto de intercambio, donde otros jugadores con suficiente dinero pueden comprarlos.
También consultarás Spanner para determinar las conexiones saldos de cuentas y cantidad de artículos, y algunas estadísticas sobre órdenes comerciales abiertas o completadas.
Por último, limpiarás los recursos que se crearon en este lab.
Qué compilarás
Como parte de este lab, aprenderás a hacer lo siguiente:
- Volver a usar la instancia de Spanner desde Introducción a Cloud Spanner con el desarrollo de juegos
- Implementa un servicio de elementos escrito en Go para controlar los jugadores que adquieren elementos y dinero.
- Implementa un servicio de Trading Post escrito en Go para simular a los jugadores que publican artículos en venta y a otros jugadores que los compran.
Qué aprenderás
- Cómo usar transacciones de lectura y escritura para garantizar la coherencia en los cambios de datos
- Cómo aprovechar las mutaciones de DML y Spanner para modificar datos
Requisitos
- Un proyecto de Google Cloud que esté conectado a una cuenta de facturación
- Un navegador, como Chrome o Firefox.
- Se completó anteriormente Introducción al desarrollo de juegos de Cloud Spanner, sin el paso de limpieza.
2. Configuración y requisitos
Completa el codelab Cómo comenzar a usar el desarrollo de juegos en Cloud Spanner
Completa el codelab Introducción al desarrollo de juegos para Cloud Spanner. Esto es necesario 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 usa para poner a la venta los artículos y comprarlos en el puesto de operaciones.
Configura las variables de entorno en Cloud Shell
Para abrir Cloud Shell, haz clic en Activar Cloud Shell (el aprovisionamiento y la conexión al entorno debería tardar solo unos minutos, ya que lo hiciste anteriormente).
Una vez conectado a Cloud Shell, deberías ver que ya estás autenticado y que el proyecto ya está configurado con tu PROJECT_ID.
Configurar las variables de entorno 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ó la 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 de los elementos del reproductor
Relaciones de órdenes comerciales
Los elementos del juego se agregan a 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
realiza un seguimiento de cualquier cambio monetario en el saldo de cuenta del jugador. Esto puede ser adquirir dinero con el botín o vender artículos en el puesto de operaciones.
Por último, la tabla trade_orders
se usa para administrar la publicación de pedidos de venta y para que los compradores completen esos pedidos.
Para crear el esquema, haz clic en el botón Write DDL
en la consola de Cloud:
Aquí, ingresarás la definición de 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 "Submit
". para modificar el esquema y espera hasta que se complete la actualización del esquema:
Cuál es el próximo paso
A continuación, implementarás el servicio del elemento.
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 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 los adquieren. 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 conexiones de Spanner se manejan de la misma manera que el servicio de perfiles y el servicio de creación de partidas 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"`
}
En primer lugar, 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 para este extremo es recuperar el valor actual de un elemento del juego y la sesión actual del jugador. Luego, inserta la información correspondiente en la tabla player_items que indica la fuente y el momento de la 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 invoca una solicitud PUT para el extremo /players/updatebalance.
La lógica para este extremo es actualizar el saldo del jugador después de aplicar el valor de amount y también actualizar la tabla player_ledger_inputs con un registro de la adquisición. Se modifica account_balance del jugador para que se muestre al emisor. El DML se usa para modificar tanto los jugadores como player_ledger_entradas.
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 correspondiente 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 que se ejecuta 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
Prueba el servicio mediante la emisión de 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, quieres que un jugador adquiera este elemento. Para hacerlo, necesitas un ItemUUID y PlayerUUID. El ItemUUID es el resultado del comando anterior. En este ejemplo, es: aecde380-0a79-48c0-ab5d-0da675d3412c
.
Para obtener un PlayerUUID, realiza una llamada 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 los jugadores asignados a juegos abiertos para poder adquirir dinero y elementos de juego.
Próximos pasos
En el siguiente paso, implementarás el servicio de publicaciones comerciales.
4. Implementa el servicio de centro comercial
Descripción general del servicio
El servicio de intercambio es una API de REST escrita en Go que aprovecha el framework de gin. En esta API, los elementos del reproductor se publican para vender. Los jugadores de juegos pueden realizar intercambios abiertos y, si tienen suficiente dinero, pueden comprar el artículo.
El archivo ./src/golang/tradepost-service/main.go para el servicio de zippost 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, además de las estructuras necesarias para las estructuras 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 comercial, se emite 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.
Las mutaciones de Spanner se eligen para crear el pedido comercial y marcar el player_item 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 PlayerItem para garantizar que pueda mostrarse para la venta. Principalmente, 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 envía una solicitud PUT al extremo /trades/buy. La información requerida es el orderUUID y el orderUUID, que es el UUID del jugador que realiza la compra.
Debido a esta complejidad y 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:
- Verifique que el pedido se pueda completar si no se completó y no está vencido.
// 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 que cotiza y tiene 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 registro que coincida.
// 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
}
- Resta el list_price del pedido del saldo de cuenta del comprador, con una entrada de registro coincidente.
// Update buyer's account balance
negAmount := o.ListPrice.Neg(&o.ListPrice)
buyer.UpdateBalance(ctx, txn, *negAmount)
- Mueve el player_item al nuevo jugador; para ello, inserta una instancia nueva del elemento del juego con los detalles del juego y del buyer en la tabla PlayerItems y quita la instancia del elemento 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
}
- Actualiza la entrada Pedidos para indicar que el elemento se completó y ya no está activo.
En conjunto, la función Comprar 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 relevante 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 el momento de ejecutar el servicio de correspondencia.
Ejecuta el servicio
Cuando ejecutes el servicio, este se ejecutará en el puerto 8083. Este servicio tiene muchas de las mismas dependencias que item-service, por lo que no se descargarán las 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
Prueba el servicio mediante 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 tradepost-service para administrar la creación de pedidos de venta. Este servicio también administra la capacidad de comprar esos pedidos.
Próximos pasos
Ahora que los servicios se están ejecutando, es el momento de simular que los jugadores venden y compran en el centro comercial.
5. Iniciar operaciones
Ahora que los servicios de artículos y publicaciones comerciales están en ejecución, 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, debes generar elementos. El archivo ./generators/item_generator.py incluye una tarea para crear elementos de juegos con cadenas de nombre aleatorias y valores de precio 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, hagamos que los jugadores adquieran elementos y dinero para que puedan participar en el puesto de operaciones. Para ello, el archivo ./generators/game_server.py proporciona tareas para recuperar elementos del juego para asignarlos a los jugadores, además de 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 les permitirá a los jugadores adquirir 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 puesto comercial
Ahora que los jugadores tienen elementos y dinero para comprarlos, pueden comenzar a usar el puesto de intercambio.
El archivo generador ./generators/trading_server.py proporciona tareas para crear pedidos de venta y completarlos.
# 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 les permitirá a los jugadores hacer una lista con los artículos que hayan adquirido para la venta y otros jugadores podrán comprarlos 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 jugadores que se registran para jugar y, luego, ejecutas simulaciones para que los jugadores jueguen con el servicio de creación de partidas. Estas simulaciones aprovecharon el framework de Locust Python para emitir solicitudes a nuestros servicios API de REST.
Puedes modificar el tiempo dedicado a crear jugadores y jugar juegos, así como la cantidad de usuarios simultáneos (-u).
Próximos pasos
Después de la simulación, consultarás a Spanner para comprobar varias estadísticas.
6. Recupera estadísticas comerciales
Ahora que simulamos que los jugadores adquieran dinero y artículos, y luego los vendamos en el puesto de operaciones, veamos algunas estadísticas.
Para ello, usa la consola de Cloud para enviar solicitudes de consulta a Spanner.
Cómo verificar los pedidos abiertos y los completados
Cuando se compra un TradeOrder en el centro de operaciones, se actualiza el campo de metadatos TradeOrder.
Esta consulta te hará revisar cuántos pedidos están abiertos y cuántos están completados:
-- 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 |
oficios completados | 454 |
Verificación del saldo de la cuenta del jugador y la cantidad de elementos
Un jugador está jugando un juego si se configuró su columna current_game. De lo contrario, no está jugando ningún juego.
Para llegar a los 10 mejores jugadores que actualmente juegan con la mayor cantidad de elementos, con su account_balance
, usa esta consulta :
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 operaciones con la consola de Cloud para consultar Spanner.
Próximos pasos
A continuación, es hora de limpiar.
7. Realice una limpieza
Después de divertirnos con Spanner, tenemos que limpiar nuestra zona de pruebas. Afortunadamente, este es un paso sencillo. Simplemente ve a la sección Cloud Spanner de la consola de Cloud y borra la instancia que creamos para este codelab.
8. ¡Felicitaciones!
Felicitaciones, implementaste correctamente un juego de muestra en Spanner
Próximos pasos
En este lab, completaste la configuración de dos servicios para controlar la generación de elementos de juego y los jugadores que adquieren artículos para venderlos en el puesto de operaciones.
Estas muestras 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.
No dudes en usar los generadores proporcionados para explorar el escalamiento de Spanner.