1. 소개
Cloud Spanner는 수평 확장이 가능하며 전 세계에 분산된 완전 관리형 관계형 데이터베이스 서비스로, 성능과 고가용성을 그대로 유지하면서 ACID 트랜잭션과 SQL 시맨틱스를 제공합니다.
이러한 특징 덕분에 Spanner는 전 세계 플레이어층을 확보하고자 하거나 데이터 일관성이 우려되는 게임의 아키텍처에 매우 적합합니다.
이 실습에서는 리전 Spanner 데이터베이스와 상호작용하여 플레이어가 아이템과 돈을 획득 (item-service
)한 다음 다른 플레이어가 구매할 수 있도록 거래처에 아이템을 나열 (tradepost-service
)하는 2개의 Go 서비스를 만듭니다.
이 실습에서는 Cloud Spanner 게임 개발 시작하기 Codelab을 기반으로 하여 profile-service
및 matchmaking-service
를 사용하여 플레이어와 게임을 생성했습니다.
다음으로 Python 로드 프레임워크인 Locust.io를 활용하여 데이터를 생성하여 '게임 플레이' 과정에서 현금과 아이템을 획득하는 플레이어를 시뮬레이션합니다. 그런 다음 플레이어는 트레이드 포스트에서 판매할 아이템을 나열하고, 충분한 돈을 모은 다른 플레이어는 이러한 아이템을 구매할 수 있습니다.
또한 Spanner를 쿼리하여 플레이어 계정 잔액, 상품 수, 진행 중이거나 완료된 거래 주문에 관한 일부 통계가 포함됩니다.
마지막으로 이 실습에서 만든 리소스를 삭제합니다.
빌드할 항목
이 실습에서 학습할 내용은 다음과 같습니다.
- Cloud Spanner 게임 개발 시작하기에서 Spanner 인스턴스를 재사용합니다.
- Go로 작성된 아이템 서비스를 배포하여 아이템과 돈을 획득하는 플레이어를 처리합니다.
- Go로 작성된 트레이딩 포스트 서비스를 배포하여 판매할 아이템을 등록하는 플레이어와 이러한 아이템을 구매하는 다른 플레이어를 시뮬레이션합니다.
학습할 내용
- 읽기-쓰기 트랜잭션을 사용하여 데이터 변경사항의 일관성을 보장하는 방법
- DML 및 Spanner 변형을 활용하여 데이터를 수정하는 방법
필요한 항목
- 결제 계정에 연결된 Google Cloud 프로젝트입니다.
- Chrome 또는 Firefox와 같은 웹브라우저
- 이전에 삭제 단계 없이 Cloud Spanner 시작하기(게임 개발 시작하기)를 완료했습니다.
2. 설정 및 요구사항
Cloud Spanner 게임 개발 시작하기 Codelab 완료
Cloud Spanner 게임 개발 시작하기 Codelab 완료 플레이어 및 게임의 데이터 세트를 가져오는 데 필요합니다. 플레이어와 게임은 아이템과 돈을 획득해야 하며, 이를 통해 판매할 아이템을 표시하고 거래처에서 아이템을 구매해야 합니다.
Cloud Shell에서 환경 변수 구성
Cloud Shell 활성화 를 클릭하여 Cloud Shell을 엽니다. 이전에 이 작업을 수행했으므로 환경을 프로비저닝하고 연결하는 데 몇 분 정도 걸립니다.
Cloud Shell에 연결되면 인증이 완료되었고 프로젝트가 PROJECT_ID로 이미 설정된 것을 확인할 수 있습니다.
Cloud Shell에서 SPANNER 환경 변수 설정
export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
스키마 만들기
이제 데이터베이스가 생성되었으므로 sample-game
데이터베이스에 스키마를 정의할 수 있습니다.
이 실습에서는 game_items
, player_items
, player_ledger_entries
, trade_orders
라는 4개의 새 테이블을 만듭니다.
플레이어 아이템 관계
무역 주문 관계
게임 항목은 game_items
테이블에 추가된 후에 플레이어가 획득할 수 있습니다. player_items
테이블에는 itemUUID와 playerUUID 모두에 대한 외래 키가 있어 플레이어가 유효한 항목만 획득하도록 합니다.
player_ledger_entries
테이블은 플레이어 계정 잔액의 금전적 변경사항을 추적합니다. 전리품에서 돈을 얻거나 교역소에서 아이템을 판매하는 것도 좋은 방법입니다.
마지막으로 trade_orders
테이블은 판매 주문 게시와 구매자가 이러한 주문을 처리하는 데 사용됩니다.
스키마를 만들려면 Cloud 콘솔에서 Write DDL
버튼을 클릭합니다.
여기에서 schema/trading.sql 파일의 스키마 정의를 입력합니다.
CREATE TABLE game_items
(
itemUUID STRING(36) NOT NULL,
item_name STRING(MAX) NOT NULL,
item_value NUMERIC NOT NULL,
available_time TIMESTAMP NOT NULL,
duration int64
)PRIMARY KEY (itemUUID);
CREATE TABLE player_items
(
playerItemUUID STRING(36) NOT NULL,
playerUUID STRING(36) NOT NULL,
itemUUID STRING(36) NOT NULL,
price NUMERIC NOT NULL,
source STRING(MAX) NOT NULL,
game_session STRING(36) NOT NULL,
acquire_time TIMESTAMP NOT NULL DEFAULT (CURRENT_TIMESTAMP()),
expires_time TIMESTAMP,
visible BOOL NOT NULL DEFAULT(true),
FOREIGN KEY (itemUUID) REFERENCES game_items (itemUUID),
FOREIGN KEY (game_session) REFERENCES games (gameUUID)
) PRIMARY KEY (playerUUID, playerItemUUID),
INTERLEAVE IN PARENT players ON DELETE CASCADE;
CREATE TABLE player_ledger_entries (
playerUUID STRING(36) NOT NULL,
source STRING(MAX) NOT NULL,
game_session STRING(36) NOT NULL,
amount NUMERIC NOT NULL,
entryDate TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
FOREIGN KEY (game_session) REFERENCES games (gameUUID)
) PRIMARY KEY (playerUUID, entryDate DESC),
INTERLEAVE IN PARENT players ON DELETE CASCADE;
CREATE TABLE trade_orders
(
orderUUID STRING(36) NOT NULL,
lister STRING(36) NOT NULL,
buyer STRING(36),
playerItemUUID STRING(36) NOT NULL,
trade_type STRING(5) NOT NULL,
list_price NUMERIC NOT NULL,
created TIMESTAMP NOT NULL DEFAULT (CURRENT_TIMESTAMP()),
ended TIMESTAMP,
expires TIMESTAMP NOT NULL DEFAULT (TIMESTAMP_ADD(CURRENT_TIMESTAMP(), interval 24 HOUR)),
active BOOL NOT NULL DEFAULT (true),
cancelled BOOL NOT NULL DEFAULT (false),
filled BOOL NOT NULL DEFAULT (false),
expired BOOL NOT NULL DEFAULT (false),
FOREIGN KEY (playerItemUUID) REFERENCES player_items (playerItemUUID)
) PRIMARY KEY (orderUUID);
CREATE INDEX TradeItem ON trade_orders(playerItemUUID, active);
‘Submit
’ 버튼을 클릭합니다. 버튼을 클릭하여 스키마를 수정하고 스키마 업데이트가 완료될 때까지 기다립니다.
다음 단계
다음으로 항목 서비스를 배포합니다.
3. 항목 서비스 배포
서비스 개요
항목 서비스는 Go로 작성된 REST API로, gin 프레임워크를 활용합니다. 이 API에서 공개 게임에 참여하는 플레이어는 현금과 아이템을 획득합니다.
./src/golang/item-service/main.go 파일은 게임 아이템 및 해당 아이템을 획득하는 플레이어와 작동하도록 다음 엔드포인트를 구성합니다. 또한 플레이어가 돈을 획득할 수 있는 엔드포인트도 있습니다.
func main() {
configuration, _ := config.NewConfig()
router := gin.Default()
router.SetTrustedProxies(nil)
router.Use(setSpannerConnection(configuration))
router.GET("/items", getItemUUIDs)
router.POST("/items", createItem)
router.GET("/items/:id", getItem)
router.PUT("/players/balance", updatePlayerBalance)
router.GET("/players", getPlayer)
router.POST("/players/items", addPlayerItem)
router.Run(configuration.Server.URL())
}
구성 및 Spanner 연결 사용은 이전 Codelab의 프로필 서비스 및 랜덤 대결 서비스와 동일하게 처리됩니다.
항목 서비스는 다음 정의가 포함된 GameItem, Player, PlayerLedger 및 PlayerItem과 함께 작동합니다.
// models/game_items.go
type GameItem struct {
ItemUUID string `json:"itemUUID"`
Item_name string `json:"item_name"`
Item_value big.Rat `json:"item_value"`
Available_time time.Time `json:"available_time"`
Duration int64 `json:"duration"`
}
// models/players.go
type Player struct {
PlayerUUID string `json:"playerUUID" binding:"required,uuid4"`
Updated time.Time `json:"updated"`
Account_balance big.Rat `json:"account_balance"`
Current_game string `json:"current_game"`
}
type PlayerLedger struct {
PlayerUUID string `json:"playerUUID" binding:"required,uuid4"`
Amount big.Rat `json:"amount"`
Game_session string `json:"game_session"`
Source string `json:"source"`
}
// models/player_items.go
type PlayerItem struct {
PlayerItemUUID string `json:"playerItemUUID" binding:"omitempty,uuid4"`
PlayerUUID string `json:"playerUUID" binding:"required,uuid4"`
ItemUUID string `json:"itemUUID" binding:"required,uuid4"`
Source string `json:"source" binding:"required"`
Game_session string `json:"game_session" binding:"omitempty,uuid4"`
Price big.Rat `json:"price"`
AcquireTime time.Time `json:"acquire_time"`
ExpiresTime spanner.NullTime `json:"expires_time"`
Visible bool `json:"visible"`
}
먼저 게임에 생성된 아이템이 있어야 합니다. 이를 위해 /items 엔드포인트에 대한 POST 요청이 호출됩니다. 이것은 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와 player_ledger_entries를 모두 수정하는 데 사용됩니다.
이는 다음 함수에 매핑됩니다.
// main.go
func updatePlayerBalance(c *gin.Context) {
var player models.Player
var ledger models.PlayerLedger
if err := c.BindJSON(&ledger); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ctx, client := getSpannerConnection(c)
err := ledger.UpdateBalance(ctx, client, &player)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
type PlayerBalance struct {
PlayerUUID, AccountBalance string
}
balance := PlayerBalance{PlayerUUID: player.PlayerUUID, AccountBalance: player.Account_balance.FloatString(2)}
c.IndentedJSON(http.StatusOK, balance)
}
// models/players.go
func (l *PlayerLedger) UpdateBalance(ctx context.Context, client spanner.Client, p *Player) error {
// Update balance with new amount
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
p.PlayerUUID = l.PlayerUUID
stmt := spanner.Statement{
SQL: `UPDATE players SET account_balance = (account_balance + @amount) WHERE playerUUID = @playerUUID`,
Params: map[string]interface{}{
"amount": l.Amount,
"playerUUID": p.PlayerUUID,
},
}
numRows, err := txn.Update(ctx, stmt)
if err != nil {
return err
}
// No rows modified. That's an error
if numRows == 0 {
errorMsg := fmt.Sprintf("Account balance for player '%s' could not be updated", p.PlayerUUID)
return errors.New(errorMsg)
}
// Get player's new balance (read after write)
stmt = spanner.Statement{
SQL: `SELECT account_balance, current_game FROM players WHERE playerUUID = @playerUUID`,
Params: map[string]interface{}{
"playerUUID": p.PlayerUUID,
},
}
iter := txn.Query(ctx, stmt)
defer iter.Stop()
for {
row, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return err
}
var accountBalance big.Rat
var gameSession string
if err := row.Columns(&accountBalance, &gameSession); err != nil {
return err
}
p.Account_balance = accountBalance
l.Game_session = gameSession
}
stmt = spanner.Statement{
SQL: `INSERT INTO player_ledger_entries (playerUUID, amount, game_session, source, entryDate)
VALUES (@playerUUID, @amount, @game, @source, PENDING_COMMIT_TIMESTAMP())`,
Params: map[string]interface{}{
"playerUUID": l.PlayerUUID,
"amount": l.Amount,
"game": l.Game_session,
"source": l.Source,
},
}
numRows, err = txn.Update(ctx, stmt)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
}
기본적으로 서비스는 환경 변수를 사용하여 구성됩니다. ./src/golang/item-service/config/config.go 파일의 관련 섹션을 참조하세요.
func NewConfig() (Config, error) {
*snip*
// Server defaults
viper.SetDefault("server.host", "localhost")
viper.SetDefault("server.port", 8082)
// Bind environment variable override
viper.BindEnv("server.host", "SERVICE_HOST")
viper.BindEnv("server.port", "SERVICE_PORT")
viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")
*snip*
return c, nil
}
기본 동작은 localhost:8082에서 서비스를 실행하는 것입니다.
이 정보를 사용하여 서비스를 실행할 차례입니다.
서비스 실행
서비스를 실행하면 종속 항목이 다운로드되고 포트 8082에서 실행되는 서비스가 설정됩니다.
cd ~/spanner-gaming-sample/src/golang/item-service
go run . &
명령어 결과:
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.
[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
- using env: export GIN_MODE=release
- using code: gin.SetMode(gin.ReleaseMode)
[GIN-debug] GET /items --> main.getItemUUIDs (4 handlers)
[GIN-debug] POST /items --> main.createItem (4 handlers)
[GIN-debug] GET /items/:id --> main.getItem (4 handlers)
[GIN-debug] PUT /players/balance --> main.updatePlayerBalance (4 handlers)
[GIN-debug] GET /players --> main.getPlayer (4 handlers)
[GIN-debug] POST /players/items --> main.addPlayerItem (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8082
curl 명령어를 실행하여 항목을 만들어 서비스를 테스트합니다.
curl http://localhost:8082/items \
--include \
--header "Content-Type: application/json" \
--request "POST" \
--data '{"item_name": "test_item","item_value": "3.14"}'
명령어 결과:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <Date>
Content-Length: 38
"aecde380-0a79-48c0-ab5d-0da675d3412c"
다음으로 플레이어가 이 아이템을 획득하도록 하세요. 이렇게 하려면 ItemUUID 및 PlayerUUID가 필요합니다. ItemUUID는 이전 명령어의 출력입니다. 이 예에서는 aecde380-0a79-48c0-ab5d-0da675d3412c
입니다.
PlayerUUID를 가져오려면 GET /players 엔드포인트를 호출합니다.
curl http://localhost:8082/players
명령어 결과:
{
"playerUUID": "b74cc194-87b0-4a55-a67f-0f0742ef6352",
"updated": "0001-01-01T00:00:00Z",
"account_balance": {},
"current_game": "7b97fa85-5658-4ded-a962-4c09269a0a79"
}
플레이어가 아이템을 획득할 수 있도록 POST /players/items 엔드포인트에 요청을 전송합니다.
curl http://localhost:8082/players/items \
--include \
--header "Content-Type: application/json" \
--request "POST" \
--data '{"playerUUID": "b74cc194-87b0-4a55-a67f-0f0742ef6352","itemUUID": "109ec745-9906-402b-9d03-ca7153a10312", "source": "loot"}'
명령어 결과:
Content-Type: application/json; charset=utf-8
Date: <Date>
Content-Length: 369
{
"playerItemUUID": "a42b1899-4509-4fce-9958-265d2a2838a0",
"playerUUID": "b74cc194-87b0-4a55-a67f-0f0742ef6352",
"itemUUID": "109ec745-9906-402b-9d03-ca7153a10312",
"source": "loot",
"game_session": "7b97fa85-5658-4ded-a962-4c09269a0a79",
"price": {},
"acquire_time": "0001-01-01T00:00:00Z",
"expires_time": null,
"visible": false
}
요약
이 단계에서는 게임 아이템을 만들 수 있는 아이템 서비스와 오픈 게임에 할당된 플레이어가 현금과 게임 아이템을 획득할 수 있도록 배포했습니다.
다음 단계
다음 단계에서는 Tradepost 서비스를 배포합니다.
4. Tradepost 서비스 배포
서비스 개요
Tradepost 서비스는 Go로 작성된 REST API로, gin 프레임워크를 활용합니다. 이 API에서는 플레이어 항목이 sell에 게시됩니다. 그런 다음 게임 플레이어는 오픈 거래를 할 수 있고, 충분한 돈을 모으면 아이템을 구매할 수 있습니다.
tradepost 서비스의 ./src/golang/tradepost-service/main.go 파일은 다른 서비스와 유사한 설정 및 코드를 따르므로 여기서 반복하지 않습니다. 이 서비스는 다음과 같이 여러 엔드포인트를 노출합니다.
func main() {
configuration, _ := config.NewConfig()
router := gin.Default()
router.SetTrustedProxies(nil)
router.Use(setSpannerConnection(configuration))
router.GET("/trades/player_items", getPlayerItem)
router.POST("/trades/sell", createOrder)
router.GET("/trades/open", getOpenOrder)
router.PUT("/trades/buy", purchaseOrder)
router.Run(configuration.Server.URL())
}
이 서비스는 TradeOrder 구조체는 물론 GameItem, PlayerItem, Player, PlayerLedger 구조체의 필수 구조체를 제공합니다.
type TradeOrder struct {
OrderUUID string `json:"orderUUID" binding:"omitempty,uuid4"`
Lister string `json:"lister" binding:"omitempty,uuid4"`
Buyer string `json:"buyer" binding:"omitempty,uuid4"`
PlayerItemUUID string `json:"playerItemUUID" binding:"omitempty,uuid4"`
TradeType string `json:"trade_type"`
ListPrice big.Rat `json:"list_price" spanner:"list_price"`
Created time.Time `json:"created"`
Ended spanner.NullTime `json:"ended"`
Expires time.Time `json:"expires"`
Active bool `json:"active"`
Cancelled bool `json:"cancelled"`
Filled bool `json:"filled"`
Expired bool `json:"expired"`
}
type GameItem struct {
ItemUUID string `json:"itemUUID"`
ItemName string `json:"item_name"`
ItemValue big.Rat `json:"item_value"`
AvailableTime time.Time `json:"available_time"`
Duration int64 `json:"duration"`
}
type PlayerItem struct {
PlayerItemUUID string `json:"playerItemUUID" binding:"omitempty,uuid4"`
PlayerUUID string `json:"playerUUID" binding:"required,uuid4"`
ItemUUID string `json:"itemUUID" binding:"required,uuid4"`
Source string `json:"source"`
GameSession string `json:"game_session" binding:"omitempty,uuid4"`
Price big.Rat `json:"price"`
AcquireTime time.Time `json:"acquire_time" spanner:"acquire_time"`
ExpiresTime spanner.NullTime `json:"expires_time" spanner:"expires_time"`
Visible bool `json:"visible"`
}
type Player struct {
PlayerUUID string `json:"playerUUID" binding:"required,uuid4"`
Updated time.Time `json:"updated"`
AccountBalance big.Rat `json:"account_balance" spanner:"account_balance"`
CurrentGame string `json:"current_game" binding:"omitempty,uuid4" spanner:"current_game"`
}
type PlayerLedger struct {
PlayerUUID string `json:"playerUUID" binding:"required,uuid4"`
Amount big.Rat `json:"amount"`
GameSession string `json:"game_session" spanner:"game_session"`
Source string `json:"source"`
}
거래 주문을 생성하려면 API 엔드포인트 /trades/sell에 POST 요청을 전송합니다. 필수 정보는 판매할 player_item의 playerItemUUID, lister, list_price입니다.
Spanner 변형을 선택하여 거래 주문을 생성하고 player_item을 표시되지 않음으로 표시합니다. 이렇게 하면 판매자가 중복 상품을 판매하는 것을 방지할 수 있습니다.
func (o *TradeOrder) Create(ctx context.Context, client spanner.Client) error {
// insert into spanner
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
// get the Item to be listed
pi, err := GetPlayerItem(ctx, txn, o.Lister, o.PlayerItemUUID)
if err != nil {
return err
}
// Set expires to 1 day by default
if o.Expires.IsZero() {
currentTime := time.Now()
o.Expires = currentTime.Add(time.Hour * 24)
}
// Item is not visible or expired, so it can't be listed. That's an error
if !validateSellOrder(pi) {
errorMsg := fmt.Sprintf("Item (%s, %s) cannot be listed.", o.Lister, o.PlayerItemUUID)
return errors.New(errorMsg)
}
// Initialize order values
o.OrderUUID = generateUUID()
o.Active = true // TODO: Have to set this by default since testing with emulator does not support 'DEFAULT' schema option
// Insert the order
var m []*spanner.Mutation
cols := []string{"orderUUID", "playerItemUUID", "lister", "list_price", "trade_type", "expires", "active"}
m = append(m, spanner.Insert("trade_orders", cols, []interface{}{o.OrderUUID, o.PlayerItemUUID, o.Lister, o.ListPrice, "sell", o.Expires, o.Active}))
// Mark the item as invisible
cols = []string{"playerUUID", "playerItemUUID", "visible"}
m = append(m, spanner.Update("player_items", cols, []interface{}{o.Lister, o.PlayerItemUUID, false}))
txn.BufferWrite(m)
return nil
})
if err != nil {
return err
}
// return empty error on success
return nil
}
실제로 주문을 생성하기 전에 PlayerItem의 유효성을 검사하여 판매용으로 표시될 수 있는지 확인합니다. 이는 주로 PlayerItem이 플레이어에게 표시되고 만료되지 않았음을 의미합니다.
// Validate that the order can be placed: Item is visible and not expired
func validateSellOrder(pi PlayerItem) bool {
// Item is not visible, can't be listed
if !pi.Visible {
return false
}
// item is expired. can't be listed
if !pi.ExpiresTime.IsNull() && pi.ExpiresTime.Time.Before(time.Now()) {
return false
}
// All validation passed. Item can be listed
return true
}
구매는 /trades/buy 엔드포인트에 대한 PUT 요청으로 진행됩니다. 필수 정보는 orderUUID 및 buyer로, 이는 구매하는 플레이어의 UUID입니다.
이러한 복잡성과 많은 변경 사항으로 인해 다시 변이를 선택하여 주문을 구매합니다. 다음 작업은 단일 읽기-쓰기 트랜잭션에서 수행됩니다.
- 이전에 작성된 적이 없고 만료되지 않았기 때문에 주문을 작성할 수 있는지 확인합니다.
// Validate that the order can be filled: Order is active and not expired
func validatePurchase(o TradeOrder) bool {
// Order is not active
if !o.Active {
return false
}
// order is expired. can't be filled
if !o.Expires.IsZero() && o.Expires.Before(time.Now()) {
return false
}
// All validation passed. Order can be filled
return true
}
- 구매자 정보를 검색하여 상품을 구매할 수 있는지 확인합니다. 즉, 구매자는 리스너와 동일할 수 없으며 충분한 자금을 보유하고 있습니다.
// Validate that a buyer can buy this item.
func validateBuyer(b Player, o TradeOrder) bool {
// Lister can't be the same as buyer
if b.PlayerUUID == o.Lister {
return false
}
// Big.rat returns -1 if Account_balance is less than price
if b.AccountBalance.Cmp(&o.ListPrice) == -1 {
return false
}
return true
}
- 일치하는 원장 항목과 함께 주문의 list_price를 lister의 계정 잔액에 추가합니다.
// models/trade_order.go
// Buy an order
func (o *TradeOrder) Buy(ctx context.Context, client spanner.Client) error {
*snip*
// Update seller's account balance
lister.UpdateBalance(ctx, txn, o.ListPrice)
*snip*
}
// models/players.go
// Update a player's balance, and add an entry into the player ledger
func (p *Player) UpdateBalance(ctx context.Context, txn *spanner.ReadWriteTransaction, newAmount big.Rat) error {
// This modifies player's AccountBalance, which is used to update the player entry
p.AccountBalance.Add(&p.AccountBalance, &newAmount)
txn.BufferWrite([]*spanner.Mutation{
spanner.Update("players", []string{"playerUUID", "account_balance"}, []interface{}{p.PlayerUUID, p.AccountBalance}),
spanner.Insert("player_ledger_entries", []string{"playerUUID", "amount", "game_session", "source", "entryDate"},
[]interface{}{p.PlayerUUID, newAmount, p.CurrentGame, "tradepost", spanner.CommitTimestamp}),
})
return nil
}
- 일치하는 원장 항목을 사용하여 구매자의 계정 잔액에서 주문의 list_price를 뺍니다.
// Update buyer's account balance
negAmount := o.ListPrice.Neg(&o.ListPrice)
buyer.UpdateBalance(ctx, txn, *negAmount)
- 게임 및 구매자 세부정보가 포함된 게임 항목의 새 인스턴스를 PlayerItems 테이블에 삽입하여 player_item을 새 플레이어로 이동하고 항목의 lister 인스턴스를 삭제합니다.
// models/player_items.go
// Move an item to a new player, removes the item entry from the old player
func (pi *PlayerItem) MoveItem(ctx context.Context, txn *spanner.ReadWriteTransaction, toPlayer string) error {
fmt.Printf("Buyer: %s", toPlayer)
txn.BufferWrite([]*spanner.Mutation{
spanner.Insert("player_items", []string{"playerItemUUID", "playerUUID", "itemUUID", "price", "source", "game_session"},
[]interface{}{pi.PlayerItemUUID, toPlayer, pi.ItemUUID, pi.Price, pi.Source, pi.GameSession}),
spanner.Delete("player_items", spanner.Key{pi.PlayerUUID, pi.PlayerItemUUID}),
})
return nil
}
- Orders(주문) 항목을 업데이트하여 항목이 채워져 더 이상 활성 상태가 아님을 나타냅니다.
모두 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/sel 엔드포인트를 호출하여 판매할 상품을 게시해 보겠습니다.
curl http://localhost:8083/trades/sell \
--include \
--header "Content-Type: application/json" \
--request "POST" \
--data '{"lister": "<PlayerUUID>","playerItemUUID": "<PlayerItemUUID>", "list_price": "<some price higher than item's price>"}'
명령어 결과:
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: <Date>
Content-Length: 38
"282ea691-b956-4c4c-95ff-f461d6415651"
요약
이 단계에서는 판매 주문 생성을 처리하기 위해 tradepost-service를 배포했습니다. 이 서비스는 이러한 주문을 구매하는 기능도 처리합니다.
다음 단계
서비스가 실행 중이므로 이제 트레이딩 포스트에서 판매 및 구매하는 플레이어를 시뮬레이션할 차례입니다.
5. 거래 시작
이제 상품 및 tradepost 서비스가 실행 중이므로 제공된 locust 생성기를 사용하여 부하를 생성할 수 있습니다.
Locust는 생성기 실행을 위한 웹 인터페이스를 제공하지만 이 실습에서는 명령줄 (–headless 옵션)을 사용합니다.
게임 아이템 생성
먼저 항목을 생성해야 합니다. ./generators/item_generator.py 파일에는 이름에 대한 임의의 문자열과 임의의 가격 값으로 게임 항목을 만드는 작업이 포함되어 있습니다.
# Generate random items
class ItemLoad(HttpUser):
def generateItemName(self):
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))
def generateItemValue(self):
return str(decimal.Decimal(random.randrange(100, 10000))/100)
@task
def createItem(self):
headers = {"Content-Type": "application/json"}
data = {"item_name": self.generateItemName(), "item_value": self.generateItemValue()}
self.client.post("/items", data=json.dumps(data), headers=headers)
다음 명령어는 10초 (t=10s) 동안 게임 항목을 생성하는 item_generator.py 파일을 호출합니다.
cd ~/spanner-gaming-sample
locust -H http://127.0.0.1:8082 -f ./generators/item_generator.py --headless -u=1 -r=1 -t=10s
명령어 결과:
*snip*
/INFO/locust.main: --run-time limit reached. Stopping Locust
/INFO/locust.main: Shutting down (exit code 0)
Name # reqs # fails | Avg Min Max Median | req/s failures/s
----------------------------------------------------------------------------------------------------------------------------------------------------------------
POST /items 606 0(0.00%) | 16 12 161 15 | 60.61 0.00
----------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregated 606 0(0.00%) | 16 12 161 15 | 60.61 0.00
Response time percentiles (approximated)
Type Name 50% 66% 75% 80% 90% 95% 98% 99% 99.9% 99.99% 100% # reqs
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
POST /items 15 16 16 17 18 19 21 34 160 160 160 606
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
None Aggregated 15 16 16 17 18 19 21 34 160 160 160 606
플레이어가 아이템과 현금 획득
다음으로 플레이어가 아이템과 돈을 획득하여 교역소에 참여하도록 해 보겠습니다. 이를 위해 ./generators/game_server.py 파일은 플레이어에게 할당할 게임 아이템과 임의의 화폐를 가져오는 작업을 제공합니다.
# Players generate items and money at 5:2 ratio. We don't want to devalue the currency!
class GameLoad(HttpUser):
def on_start(self):
self.getItems()
def getItems(self):
headers = {"Content-Type": "application/json"}
r = requests.get(f"{self.host}/items", headers=headers)
global itemUUIDs
itemUUIDs = json.loads(r.text)
def generateAmount(self):
return str(round(random.uniform(1.01, 49.99), 2))
@task(2)
def acquireMoney(self):
headers = {"Content-Type": "application/json"}
# Get a random player that's part of a game, and update balance
with self.client.get("/players", headers=headers, catch_response=True) as response:
try:
data = {"playerUUID": response.json()["playerUUID"], "amount": self.generateAmount(), "source": "loot"}
self.client.put("/players/balance", data=json.dumps(data), headers=headers)
except json.JSONDecodeError:
response.failure("Response could not be decoded as JSON")
except KeyError:
response.failure("Response did not contain expected key 'playerUUID'")
@task(5)
def acquireItem(self):
headers = {"Content-Type": "application/json"}
# Get a random player that's part of a game, and add an item
with self.client.get("/players", headers=headers, catch_response=True) as response:
try:
itemUUID = itemUUIDs[random.randint(0, len(itemUUIDs)-1)]
data = {"playerUUID": response.json()["playerUUID"], "itemUUID": itemUUID, "source": "loot"}
self.client.post("/players/items", data=json.dumps(data), headers=headers)
except json.JSONDecodeError:
response.failure("Response could not be decoded as JSON")
except KeyError:
response.failure("Response did not contain expected key 'playerUUID'")
플레이어는 다음 명령어를 사용하여 60초 동안 아이템과 돈을 획득할 수 있습니다.
locust -H http://127.0.0.1:8082 -f game_server.py --headless -u=1 -r=1 -t=60s
명령어 결과:
*snip*
dev-machine/INFO/locust.main: --run-time limit reached. Stopping Locust
dev-machine/INFO/locust.main: Shutting down (exit code 0)
Name # reqs # fails | Avg Min Max Median | req/s failures/s
----------------------------------------------------------------------------------------------------------------------------------------------------------------
GET /players 231 0(0.00%) | 14 9 30 13 | 23.16 0.00
PUT /players/balance 53 0(0.00%) | 33 30 39 34 | 5.31 0.00
POST /players/items 178 0(0.00%) | 26 22 75 26 | 17.85 0.00
----------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregated 462 0(0.00%) | 21 9 75 23 | 46.32 0.00
Response time percentiles (approximated)
Type Name 50% 66% 75% 80% 90% 95% 98% 99% 99.9% 99.99% 100% # reqs
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
GET /players 13 16 17 17 19 20 21 23 30 30 30 231
PUT /players/balance 34 34 35 35 36 37 38 40 40 40 40 53
POST /players/items 26 27 27 27 28 29 34 53 76 76 76 178
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
None Aggregated 23 26 27 27 32 34 36 37 76 76 76 462
교역소에서 사고파는 플레이어
이제 플레이어는 아이템을 획득하고 아이템을 구매할 수 있는 돈이 있으므로 교역소를 사용할 수 있습니다.
./generators/trading_server.py 생성기 파일은 판매 주문을 만들고 해당 주문을 처리하는 작업을 제공합니다.
# Players can sell and buy items
class TradeLoad(HttpUser):
def itemMarkup(self, value):
f = float(value)
return str(f*1.5)
@task
def sellItem(self):
headers = {"Content-Type": "application/json"}
# Get a random item
with self.client.get("/trades/player_items", headers=headers, catch_response=True) as response:
try:
playerUUID = response.json()["PlayerUUID"]
playerItemUUID = response.json()["PlayerItemUUID"]
list_price = self.itemMarkup(response.json()["Price"])
# Currently don't have any items that can be sold, retry
if playerItemUUID == "":
raise RescheduleTask()
data = {"lister": playerUUID, "playerItemUUID": playerItemUUID, "list_price": list_price}
self.client.post("/trades/sell", data=json.dumps(data), headers=headers)
except json.JSONDecodeError:
response.failure("Response could not be decoded as JSON")
except KeyError:
response.failure("Response did not contain expected key 'playerUUID'")
@task
def buyItem(self):
headers = {"Content-Type": "application/json"}
# Get a random item
with self.client.get("/trades/open", headers=headers, catch_response=True) as response:
try:
orderUUID = response.json()["OrderUUID"]
buyerUUID = response.json()["BuyerUUID"]
# Currently don't have any buyers that can fill the order, retry
if buyerUUID == "":
raise RescheduleTask()
data = {"orderUUID": orderUUID, "buyer": buyerUUID}
self.client.put("/trades/buy", data=json.dumps(data), headers=headers)
except json.JSONDecodeError:
response.failure("Response could not be decoded as JSON")
except KeyError:
response.failure("Response did not contain expected key 'playerUUID'")
이 명령어를 사용하면 플레이어가 아이템을 판매용으로 획득하고 다른 플레이어가 해당 아이템을 10초 동안 구매할 수 있게 됩니다.
locust -H http://127.0.0.1:8083 -f ./generators/trading_server.py --headless -u=1 -r=1 -t=10s
명령어 결과:
*snip*
Name # reqs # fails | Avg Min Max Median | req/s failures/s
----------------------------------------------------------------------------------------------------------------------------------------------------------------
PUT /trades/buy 20 5(25.00%) | 43 10 78 43 | 2.07 0.52
GET /trades/open 20 0(0.00%) | 358 7 971 350 | 2.07 0.00
GET /trades/player_items 20 0(0.00%) | 49 35 113 41 | 2.07 0.00
POST /trades/sell 20 0(0.00%) | 29 21 110 24 | 2.07 0.00
----------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregated 80 5(6.25%) | 120 7 971 42 | 8.29 0.52
Response time percentiles (approximated)
Type Name 50% 66% 75% 80% 90% 95% 98% 99% 99.9% 99.99% 100% # reqs
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
PUT /trades/buy 43 45 49 50 71 78 78 78 78 78 78 20
GET /trades/open 360 500 540 550 640 970 970 970 970 970 970 20
GET /trades/player_items 43 55 57 59 72 110 110 110 110 110 110 20
POST /trades/sell 24 25 25 27 50 110 110 110 110 110 110 20
--------|--------------------------------------------------------------------------------|---------|------|------|------|------|------|------|------|------|------|------|------|
None Aggregated 42 50 71 110 440 550 640 970 970 970 970 80
요약
이 단계에서는 게임 플레이에 가입하는 플레이어를 시뮬레이션한 다음 랜덤 대결 서비스를 사용하여 플레이어가 게임을 플레이할 수 있도록 시뮬레이션을 실행했습니다. 이 시뮬레이션에서는 Locust Python 프레임워크를 활용하여 Google 서비스에 대한 요청을 REST API를 사용할 수 있습니다.
플레이어를 만들고 게임을 플레이하는 데 소요된 시간은 물론 동시 사용자 수 (-u)도 자유롭게 수정할 수 있습니다.
다음 단계
시뮬레이션 후 Spanner를 쿼리하여 다양한 통계를 확인할 수 있습니다.
6. 거래 통계 가져오기
플레이어가 돈과 아이템을 획득하고 교역소에서 그 아이템을 판매하는 것을 시뮬레이션했으므로 몇 가지 통계를 확인해 보겠습니다.
이렇게 하려면 Cloud 콘솔을 사용하여 Spanner에 쿼리 요청을 실행하세요.
미결 거래 주문과 처리된 거래 주문 확인하기
교역소에서 TradeOrder가 구매되면 TradeOrder 메타데이터 필드가 업데이트됩니다.
이 쿼리를 사용하면 진행 중인 주문 수와 완료된 주문 수를 확인할 수 있습니다.
-- Open vs Filled Orders
SELECT Type, NumTrades FROM
(SELECT "Open Trades" as Type, count(*) as NumTrades FROM trade_orders WHERE active=true
UNION ALL
SELECT "Filled Trades" as Type, count(*) as NumTrades FROM trade_orders WHERE filled=true
)
결과:
유형 | NumTrades |
미결 거래 | 159 |
채워진 거래 | 454 |
플레이어 계정 잔액 및 아이템 수 확인
current_game 열이 설정된 경우 플레이어가 게임을 하고 있는 것입니다. 그렇지 않으면 현재 게임을 하고 있지 않습니다.
account_balance와 함께 현재 가장 많은 아이템이 있는 게임을 플레이 중인 상위 10명의 플레이어로 이동
, 다음 쿼리를 사용합니다.
SELECT playerUUID, account_balance, (SELECT COUNT(*) FROM player_items WHERE playerUUID=p.PlayerUUID) AS numItems, current_game
FROM players AS p
WHERE current_game IS NOT NULL
ORDER BY numItems DESC
LIMIT 10;
결과:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
요약
이 단계에서는 Cloud 콘솔을 사용하여 Spanner를 쿼리하여 플레이어 및 거래 주문의 다양한 통계를 검토했습니다.
다음 단계
이제 정리할 시간입니다.
7. 삭제
Spanner를 마음껏 사용한 후에는 플레이그라운드를 정리해야 합니다. 다행히 이 단계는 간단합니다. Cloud 콘솔의 Cloud Spanner 섹션으로 이동하여 이 Codelab용으로 만든 인스턴스를 삭제하면 됩니다.
8. 축하합니다.
수고하셨습니다. Spanner에 샘플 게임을 배포했습니다.
다음 단계
이 실습에서는 게임 아이템 생성을 처리하는 서비스와 플레이어가 교역소에서 판매할 아이템을 획득하는 서비스를 처리하는 두 가지 서비스를 설정했습니다.
이 코드 샘플은 트랜잭션 내 Cloud Spanner 일관성이 DML 및 Spanner 변형 모두에서 어떻게 작동하는지를 이해하는 데 도움이 됩니다.
제공된 생성기를 자유롭게 사용하여 Spanner 확장을 살펴보세요.