Cloud Spanner 게임 트레이딩 게시물

1. 소개

Cloud Spanner는 성능 및 고가용성을 희생하지 않고도 ACID 트랜잭션 및 SQL 시맨틱스를 제공하는 수평 확장이 가능하고, 전역으로 분산되었고, 완전히 관리되는 관계형 데이터베이스 서비스입니다.

이러한 기능 덕분에 Spanner는 글로벌 플레이어 기반을 지원하거나 데이터 일관성을 우려하는 게임의 아키텍처에 적합합니다.

이 실습에서는 리전 Spanner 데이터베이스와 상호작용하는 Go 서비스 두 개를 만들어 플레이어가 아이템과 돈 (item-service)을 획득할 수 있도록 하고 다른 플레이어가 구매할 수 있도록 거래소에 아이템을 나열합니다 (tradepost-service).

이 실습에서는 profile-servicematchmaking-service를 사용하여 플레이어와 게임을 생성하는 Cloud Spanner 게임 개발 시작하기 Codelab을 완료해야 합니다.

904c5193ee27626a.png

다음으로 Python 로드 프레임워크 Locust.io를 활용하여 '게임 플레이' 중에 플레이어가 돈과 아이템을 획득하는 것을 시뮬레이션하는 데이터를 생성합니다. 그러면 플레이어가 거래소에 판매할 아이템을 등록할 수 있으며, 충분한 돈이 있는 다른 플레이어가 해당 아이템을 구매할 수 있습니다.

또한 Spanner를 쿼리하여 플레이어의 계정 잔액과 상품 수, 진행 중이거나 완료된 거래 주문에 관한 일부 통계를 확인합니다.

마지막으로 이 실습에서 만든 리소스를 정리합니다.

빌드할 항목

이 실습에서 학습할 내용은 다음과 같습니다.

  • Cloud Spanner 게임 개발 시작하기의 Spanner 인스턴스를 재사용합니다.
  • 플레이어가 아이템과 돈을 획득하는 것을 처리하기 위해 Go로 작성된 Item 서비스를 배포합니다.
  • Go로 작성된 거래소 서비스를 배포하여 판매용 아이템을 나열하는 플레이어와 해당 아이템을 구매하는 다른 플레이어를 시뮬레이션합니다.

학습할 내용

  • 읽기-쓰기 트랜잭션을 사용하여 데이터 변경사항의 일관성을 보장하는 방법
  • DML 및 Spanner 변형을 활용하여 데이터를 수정하는 방법

필요한 항목

2. 설정 및 요구사항

Cloud Spanner 게임 개발 시작하기 Codelab 완료

Cloud Spanner 게임 개발 시작하기 Codelab을 완료합니다. 플레이어 및 경기 데이터 세트를 가져오는 데 필요합니다. 플레이어와 게임은 아이템과 돈을 획득해야 하며, 이는 거래소에서 판매용 아이템을 등록하고 아이템을 구매하는 데 사용됩니다.

Cloud Shell에서 환경 변수 구성

Cloud Shell 활성화 gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A를 클릭하여 Cloud Shell을 엽니다 (이전에 이 작업을 수행했으므로 환경을 프로비저닝하고 연결하는 데 몇 분 정도만 소요됩니다).

JjEuRXGg0AYYIY6QZ8d-66gx_Mtc-_jDE9ijmbXLJSAXFvJt-qUpNtsBsYjNpv2W6BQSrDc1D-ARINNQ-1EkwUhz-iUK-FUCZhJ-NtjvIEx9pIkE-246DomWuCfiGHK78DgoeWkHRw

Screen Shot 2017-06-14 at 10.13.43 PM.png

Cloud Shell에 연결되면 인증이 완료되었고 프로젝트가 해당 PROJECT_ID로 이미 설정된 것을 확인할 수 있습니다.

Cloud Shell에서 SPANNER 환경 변수 설정

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

스키마 만들기

이제 데이터베이스가 생성되었으므로 sample-game 데이터베이스에서 스키마를 정의할 수 있습니다.

이 실습에서는 game_items, player_items, player_ledger_entries, trade_orders의 네 가지 새 테이블을 만듭니다.

402ce3310dd7141a.png

플레이어 항목 관계

5e1229d85b75906.png

거래 주문 관계

게임 아이템이 game_items 표에 추가된 후 플레이어가 획득할 수 있습니다. player_items 테이블에는 플레이어가 유효한 항목만 획득하도록 itemUUID와 playerUUID에 대한 외래 키가 있습니다.

player_ledger_entries 표는 플레이어의 계정 잔액에 대한 금전적 변경사항을 추적합니다. 전리품에서 돈을 획득하거나 거래소에서 아이템을 판매하는 방법이 있습니다.

마지막으로 trade_orders 테이블은 판매 주문을 게시하고 구매자가 해당 주문을 처리하는 데 사용됩니다.

스키마를 만들려면 Cloud 콘솔에서 Write DDL 버튼을 클릭합니다.

e9ad931beb1d96b.png

여기에서 schema/trading.sql 파일의 스키마 정의를 입력합니다.

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

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

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

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

CREATE INDEX TradeItem ON trade_orders(playerItemUUID, active);

'Submit' 버튼을 클릭하여 스키마를 수정하고 스키마 업데이트가 완료될 때까지 기다립니다.

94f44b2774bce914.png

다음 단계

다음으로 항목 서비스를 배포합니다.

3. 상품 서비스 배포

서비스 개요

상품 서비스는 gin 프레임워크를 활용하는 Go로 작성된 REST API입니다. 이 API에서 공개 게임에 참여하는 플레이어는 돈과 아이템을 획득합니다.

6c29a0831b5f588d.png

./src/golang/item-service/main.go 파일은 게임 아이템 및 해당 아이템을 획득하는 플레이어와 함께 작동하도록 다음 엔드포인트를 구성합니다. 또한 플레이어가 돈을 획득할 수 있는 엔드포인트가 있습니다.

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

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

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

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

Spanner 연결 구성 및 사용은 이전 Codelab의 profile-service 및 matchmaking-service와 정확히 동일하게 처리됩니다.

상품 서비스는 다음 정의를 사용하여 GameItem, Player, PlayerLedgerPlayerItem과 함께 작동합니다.

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

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

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

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

먼저 게임에 생성된 아이템이 있어야 합니다. 이를 위해 /items 엔드포인트에 대한 POST 요청이 호출됩니다. 이는 game_items 테이블에 삽입하는 매우 간단한 DML입니다.

 // 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 요청이 호출됩니다.

이 엔드포인트의 로직은 amount를 적용한 후 플레이어의 잔액을 업데이트하고 획득 기록으로 player_ledger_entries 테이블을 업데이트하는 것입니다. 플레이어의 account_balance가 수정되어 호출자에게 반환됩니다. DML은 플레이어와 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"

다음으로 플레이어가 이 아이템을 획득해야 합니다. 이렇게 하려면 ItemUUIDPlayerUUID가 필요합니다. 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. 거래 게시판 서비스 배포

서비스 개요

거래 게시판 서비스는 gin 프레임워크를 활용하는 Go로 작성된 REST API입니다. 이 API에서 플레이어 상품은 sell에 게시됩니다. 그러면 게임 플레이어가 공개된 거래를 확인하고 돈이 충분하면 상품을 구매할 수 있습니다.

c32372f9def89a4a.png

트레이드포스트 서비스의 ./src/golang/tradepost-service/main.go 파일은 다른 서비스와 유사한 설정과 코드를 따르므로 여기서는 반복하지 않습니다. 이 서비스는 다음과 같이 여러 엔드포인트를 노출합니다.

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

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

   router.Use(setSpannerConnection(configuration))

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

이 서비스는 TradeOrder 구조체와 GameItem, PlayerItem, PlayerPlayerLedger 구조체에 필요한 구조체를 제공합니다.

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

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

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

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

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

거래 주문을 생성하려면 API 엔드포인트 /trades/sell에 POST 요청을 실행합니다. 필수 정보는 판매할 player_item의 playerItemUUID, lister, list_price입니다.

Spanner 변이는 거래 주문을 만들고 player_item을 visible이 아닌 것으로 표시하는 데 사용됩니다. 이렇게 하면 판매자가 중복 상품을 판매용으로 게시할 수 없습니다.

 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인 buyer입니다.

이러한 복잡성과 변경사항의 양으로 인해 변이가 다시 주문을 구매하도록 선택됩니다. 다음 작업은 단일 읽기-쓰기 트랜잭션에서 실행됩니다.

  • 이전에 주문이 처리되지 않았고 만료되지 않았으므로 주문을 처리할 수 있는지 확인합니다.
 // 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_pricelister 계정 잔액에 추가하고 일치하는 장부 항목을 추가합니다.
 // 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_pricebuyer's 계정 잔액에서 차감하고 일치하는 장부 항목을 만듭니다.
 // Update buyer's account balance
       negAmount := o.ListPrice.Neg(&o.ListPrice)
       buyer.UpdateBalance(ctx, txn, *negAmount)
  • 게임 및 구매자 세부정보가 포함된 게임 항목의 새 인스턴스를 PlayerItems 테이블에 삽입하여 player_item을 새 플레이어로 이동하고 항목의 등록자 인스턴스를 삭제합니다.
// 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

항목 게시

판매할 PlayerItem을 가져오는 GET 요청을 실행하여 서비스를 테스트합니다.

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

명령어 출력:

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

이제 /trades/sell 엔드포인트를 호출하여 판매할 상품을 게시해 보겠습니다.

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

명령어 출력:

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

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

요약

이 단계에서는 판매 주문 생성을 처리하는 tradepost-service를 배포했습니다. 이 서비스는 이러한 주문을 구매하는 기능도 처리합니다.

다음 단계

이제 서비스가 실행되었으므로 거래소에서 플레이어가 판매하고 구매하는 것을 시뮬레이션할 차례입니다.

5. 거래 시작

이제 상품 및 거래소 서비스가 실행 중이므로 제공된 locust 생성기를 사용하여 부하를 생성할 수 있습니다.

Locust는 생성기를 실행하기 위한 웹 인터페이스를 제공하지만 이 실습에서는 명령줄 (–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초 동안 게임 아이템을 생성하는 item_generator.py 파일을 호출합니다 (t=10s).

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

명령어 출력:

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

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

플레이어가 아이템과 돈을 획득합니다.

이제 플레이어가 거래소에 참여할 수 있도록 아이템과 돈을 획득하도록 하겠습니다. 이를 위해 ./generators/game_server.py 파일은 플레이어에게 할당할 게임 아이템과 무작위 금액의 통화를 가져오는 작업을 제공합니다.

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

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

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

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

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

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

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

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

이 명령어를 사용하면 플레이어가 60초 동안 아이템과 돈을 획득할 수 있습니다.

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

명령어 출력:

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

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

거래소에서 구매 및 판매하는 플레이어

이제 플레이어가 아이템을 구매할 수 있는 돈과 아이템을 보유하고 있으므로 거래 게시판을 사용할 수 있습니다.

./generators/trading_server.py 생성기 파일은 판매 주문을 생성하고 이러한 주문을 처리하는 작업을 제공합니다.

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

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

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

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


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

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

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

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

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

이 명령어를 사용하면 플레이어가 획득한 아이템을 판매용으로 등록하고 다른 플레이어가 10초 동안 해당 아이템을 구매할 수 있습니다.

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

명령어 출력:

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

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

요약

이 단계에서는 게임을 플레이하기 위해 가입하는 플레이어를 시뮬레이션한 다음 매치메이킹 서비스를 사용하여 게임을 플레이하는 플레이어를 시뮬레이션했습니다. 이러한 시뮬레이션에서는 Locust Python 프레임워크를 활용하여 서비스의 REST API에 요청을 발행했습니다.

플레이어를 만들고 게임을 플레이하는 데 걸리는 시간과 동시 사용자 수 (-u)를 자유롭게 수정하세요.

다음 단계

시뮬레이션 후 Spanner를 쿼리하여 다양한 통계를 확인해야 합니다.

6. 거래 통계 가져오기

이제 플레이어가 돈과 아이템을 획득한 후 거래소에서 해당 아이템을 판매하는 것을 시뮬레이션했으므로 몇 가지 통계를 확인해 보겠습니다.

이렇게 하려면 Cloud Console을 사용하여 Spanner에 쿼리 요청을 실행합니다.

b5e3154c6f7cb0cf.png

진행 중인 보상 판매 주문과 처리된 보상 판매 주문 확인

거래소에서 TradeOrder를 구매하면 filled 메타데이터 필드가 업데이트됩니다.

이 쿼리를 사용하면 미처리 주문 수와 처리된 주문 수를 확인할 수 있습니다.

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

결과:

유형

NumTrades

Open Trades(열린 거래)

159

체결된 거래

454

플레이어 계정 잔액 및 항목 수 확인

current_game 열이 설정된 경우 플레이어가 게임을 플레이하고 있는 것입니다. 그렇지 않으면 현재 게임을 플레이하고 있지 않습니다.

현재 게임을 플레이 중이며 아이템이 가장 많은 상위 10명의 플레이어를 account_balance와 함께 가져옵니다.

, 다음 쿼리를 사용하세요.

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

결과:

playerUUID

account_balance

numItems

current_game

04d14288-a7c3-4515-9296-44eeae184d6d

1396.55

201

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

03836948-d591-4967-a8a8-80506454916d

2005.085

192

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


snip


snip


snip


snip

요약

이 단계에서는 Cloud 콘솔을 사용하여 Spanner를 쿼리하여 플레이어 및 거래 주문의 다양한 통계를 검토했습니다.

다음 단계

이제 정리할 시간입니다.

7. 삭제

Spanner에 대한 모든 학습이 완료되었으면 사용된 리소스를 삭제해야 합니다. 이 단계는 매우 간단합니다. Cloud Console의 Cloud Spanner 섹션으로 이동하고 이 Codelab을 위해 만든 인스턴스만 삭제하면 됩니다.

8. 축하합니다.

축하합니다. Spanner에 샘플 게임을 배포했습니다.

다음 단계

이 실습에서는 게임 아이템 생성과 플레이어가 거래소에서 판매할 아이템을 획득하는 것을 처리하는 두 서비스를 설정했습니다.

이러한 코드 샘플을 통해 DML 및 Spanner 변이 모두에서 트랜잭션 내 Cloud Spanner 일관성이 어떻게 작동하는지 더 잘 이해할 수 있습니다.

제공된 생성기를 사용하여 Spanner 확장을 자유롭게 살펴볼 수 있습니다.