Cloud Spanner 게임 개발 시작하기

1. 소개

Cloud Spanner는 수평 확장이 가능하며 전 세계에 분산된 완전 관리형 관계형 데이터베이스 서비스로, 성능과 고가용성을 그대로 유지하면서 ACID 트랜잭션과 SQL 시맨틱스를 제공합니다.

이러한 특징 덕분에 Spanner는 전 세계 플레이어층을 확보하고자 하거나 데이터 일관성이 우려되는 게임의 아키텍처에 매우 적합합니다.

이 실습에서는 플레이어가 가입하고 플레이를 시작할 수 있도록 리전 Spanner 데이터베이스와 상호작용하는 2개의 Go 서비스를 만듭니다.

413fdd57bb0b68bc.png

다음으로 Python 로드 프레임워크인 Locust.io를 활용하여 데이터를 생성하여 플레이어가 가입하고 게임을 플레이하는 것을 시뮬레이션합니다. 그런 다음 Spanner에 쿼리하여 플레이 중인 플레이어 수와 플레이어의 이긴 게임 수 vs. 플레이한 게임 수

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

빌드할 항목

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

  • Spanner 인스턴스 만들기
  • Go로 작성된 프로필 서비스를 배포하여 플레이어 가입을 처리합니다.
  • Go로 작성된 랜덤 대결 서비스를 배포하여 플레이어를 게임에 할당하고, 승자를 결정하고, 플레이어의 점수를 업데이트합니다. 게임 통계

학습할 내용

  • Cloud Spanner 인스턴스 설정 방법
  • 게임 데이터베이스 및 스키마를 만드는 방법
  • Cloud Spanner와 함께 작동하도록 Go 앱을 배포하는 방법
  • Locust를 사용하여 데이터를 생성하는 방법
  • Cloud Spanner에서 데이터를 쿼리하여 게임과 플레이어에 관한 질문에 답하는 방법

필요한 항목

  • 결제 계정에 연결된 Google Cloud 프로젝트입니다.
  • Chrome 또는 Firefox와 같은 웹브라우저

2. 설정 및 요건

프로젝트 만들기

아직 Google 계정(Gmail 또는 Google Apps)이 없으면 계정을 만들어야 합니다. Google Cloud Platform 콘솔 ( console.cloud.google.com)에 로그인하여 새 프로젝트를 만듭니다.

프로젝트가 이미 있으면 Console 왼쪽 위에서 프로젝트 선택 풀다운 메뉴를 클릭합니다.

6c9406d9b014760.png

그리고 표시된 대화상자에서 '새 프로젝트' 버튼을 클릭하여 새 프로젝트를 만듭니다.

949d83c8a4ee17d9.png

아직 프로젝트가 없으면 첫 번째 프로젝트를 만들기 위해 다음과 비슷한 대화상자가 표시됩니다.

870a3cbd6541ee86.png

이후의 프로젝트 만들기 대화상자에서 새 프로젝트의 세부정보를 입력할 수 있습니다.

6a92c57d3250a4b3.png

모든 Google Cloud 프로젝트에서 고유한 이름인 프로젝트 ID를 기억하세요(위의 이름은 이미 사용되었으므로 사용할 수 없습니다). 이 이름은 나중에 Codelab에서 PROJECT_ID로 참조됩니다.

그런 다음 Google Cloud 리소스를 사용하고 Cloud Spanner API를 사용 설정하기 위해서는 아직 완료하지 않은 경우 Developers Console에서 결제를 사용 설정해야 합니다.

15d0ef27a8fbab27.png

이 codelab을 실행하는 과정에는 많은 비용이 들지 않지만 더 많은 리소스를 사용하려고 하거나 실행 중일 경우 비용이 더 들 수 있습니다(이 문서 마지막의 '삭제' 섹션 참조). Google Cloud Spanner 가격 책정은 여기를 참조하세요.

Google Cloud Platform 신규 사용자는 $300 상당의 무료 체험판을 사용할 수 있으므로, 이 Codelab을 완전히 무료로 사용할 수 있습니다.

Google Cloud Shell 설정

Google Cloud 및 Spanner를 노트북에서 원격으로 실행할 수 있지만, 이 Codelab에서는 Cloud에서 실행되는 명령줄 환경인 Google Cloud Shell을 사용합니다.

이 Debian 기반 가상 머신에는 필요한 모든 개발 도구가 로드되어 있습니다. 영구적인 5GB 홈 디렉터리를 제공하고 Google Cloud에서 실행되므로 네트워크 성능과 인증이 크게 개선됩니다. 즉, 이 Codelab에 필요한 것은 브라우저뿐입니다(Chromebook에서도 작동 가능).

  1. Cloud 콘솔에서 Cloud Shell을 활성화하려면 Cloud Shell 활성화 gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A를 클릭하세요. 환경을 프로비저닝하고 연결하는 데 몇 분 정도 걸립니다.

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로 이미 설정된 것을 확인할 수 있습니다.

gcloud auth list

명령어 결과

Credentialed accounts:
 - <myaccount>@<mydomain>.com (active)
gcloud config list project

명령어 결과

[core]
project = <PROJECT_ID>

어떤 이유로든 프로젝트가 설정되지 않았으면 다음 명령어를 실행하면 됩니다.

gcloud config set project <PROJECT_ID>

PROJECT_ID를 찾고 계신가요? 설정 단계에서 사용한 ID를 확인하거나 Cloud Console 대시보드에서 확인하세요.

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

또한 Cloud Shell은 기본적으로 이후 명령어를 실행할 때 유용할 수 있는 몇 가지 환경 변수를 설정합니다.

echo $GOOGLE_CLOUD_PROJECT

명령어 결과

<PROJECT_ID>

코드 다운로드

Cloud Shell에서 이 실습의 코드를 다운로드할 수 있습니다. 이는 v0.1.0 버전을 기반으로 하므로 해당 태그를 확인하세요.

git clone https://github.com/cloudspannerecosystem/spanner-gaming-sample.git
cd spanner-gaming-sample/

# Check out v0.1.0 release
git checkout tags/v0.1.0 -b v0.1.0-branch

명령어 결과

Cloning into 'spanner-gaming-sample'...
*snip*
Switched to a new branch 'v0.1.0-branch'

Locust 부하 생성기 설정

Locust는 REST API 엔드포인트를 테스트하는 데 유용한 Python 부하 테스트 프레임워크입니다. 이 Codelab의 '생성기'에는 두 가지 부하 테스트가 있습니다. 디렉터리:

  • authentication_server.py: 플레이어를 만들고 임의의 플레이어가 단일 포인트 조회를 모방하도록 하는 작업을 포함합니다.
  • match_server.py: 게임을 만들고 게임을 닫는 작업을 포함합니다. 게임을 만들면 현재 게임을 하고 있지 않은 플레이어 100명이 무작위로 할당됩니다. 게임을 종료하면 games_played 및 games_won 통계를 업데이트하고 이러한 플레이어를 향후 게임에 할당할 수 있습니다.

Cloud Shell에서 Locust를 실행하려면 Python 3.7 이상이 필요합니다. Cloud Shell은 Python 3.9와 함께 제공되므로 버전 유효성 검사만 하면 됩니다.

python -V

명령어 결과

Python 3.9.12

이제 Locust의 요구사항을 설치할 수 있습니다.

pip3 install -r requirements.txt

명령어 결과

Collecting locust==2.11.1
*snip*
Successfully installed ConfigArgParse-1.5.3 Flask-BasicAuth-0.2.0 Flask-Cors-3.0.10 brotli-1.0.9 gevent-21.12.0 geventhttpclient-2.0.2 greenlet-1.1.3 locust-2.11.1 msgpack-1.0.4 psutil-5.9.2 pyzmq-22.3.0 roundrobin-0.0.4 zope.event-4.5.0 zope.interface-5.4.0

이제 새로 설치된 locust 바이너리를 찾을 수 있도록 PATH를 업데이트합니다.

PATH=~/.local/bin":$PATH"
which locust

명령어 결과

/home/<user>/.local/bin/locust

요약

이 단계에서는 Cloud Shell이 아직 없다면 프로젝트를 설정하고, Cloud Shell을 활성화하고, 이 실습용 코드를 다운로드했습니다.

마지막으로 실습 후반부에서 로드 생성을 위해 Locust를 설정합니다.

다음 단계

다음으로 Cloud Spanner 인스턴스와 데이터베이스를 설정합니다.

3. Spanner 인스턴스 및 데이터베이스 만들기

Spanner 인스턴스 만들기

이 단계에서는 Codelab을 위한 Spanner 인스턴스를 설정합니다. 왼쪽 위에 있는 햄버거 메뉴 3129589f7bc9e5ce.png에서 Spanner 항목 1a6580bd3d3e6783.png을 검색하거나 '/'를 누르고 'Spanner'를 입력하여 Spanner를 검색합니다.

36e52f8df8e13b99.png

그런 다음 95269e75bc8c3e4d.png를 클릭하고, 인스턴스의 인스턴스 이름 cloudspanner-gaming을 입력하고, 구성을 선택하고 (us-central1 등의 리전 인스턴스 선택), 노드 수를 설정하여 양식을 작성합니다. 이 Codelab에서는 500 processing units만 필요합니다.

마지막으로 '만들기'를 클릭하면 몇 초 지나지 않아 Cloud Spanner 인스턴스가 준비됩니다.

4457c324c94f93e6.png

데이터베이스 및 스키마 만들기

인스턴스가 실행되면 데이터베이스를 만들 수 있습니다. Spanner는 단일 인스턴스에서 여러 데이터베이스를 허용합니다.

데이터베이스는 스키마를 정의하는 곳입니다. 또한 데이터베이스에 액세스할 수 있는 사용자를 제어하고, 커스텀 암호화를 설정하고, 최적화 도구를 구성하고, 보관 기간을 설정할 수 있습니다.

멀티 리전 인스턴스에서는 기본 리더도 구성할 수 있습니다. Spanner의 데이터베이스에 대해 자세히 알아보세요.

이 Codelab에서는 기본 옵션으로 데이터베이스를 만들고 생성 시 스키마를 제공합니다.

이 실습에서는 플레이어게임이라는 두 개의 테이블을 만듭니다.

77651ac12e47fe2a.png

플레이어는 시간이 지남에 따라 많은 게임에 참여할 수 있지만 한 번에 한 게임만 참여할 수 있습니다. 또한 플레이어는 statsJSON 데이터 유형으로 사용하여 games_playedgames_won과 같은 흥미로운 통계를 추적할 수 있습니다. 다른 통계가 나중에 추가될 수 있으므로 이 열은 플레이어에게 사실상 스키마가 없는 열입니다.

게임은 Spanner의 ARRAY 데이터 유형을 사용하여 참여한 플레이어를 추적합니다. 게임의 승자 및 완료된 속성은 게임이 종료될 때까지 채워지지 않습니다.

플레이어의 current_game이 유효한 게임인지 확인하는 외래 키가 하나 있습니다.

이제 '데이터베이스 만들기'를 클릭하여 데이터베이스를 만듭니다. 인스턴스 개요에서 다음을 참조하세요.

a820db6c4a4d6f2d.png

그런 다음 세부정보를 입력합니다. 중요한 옵션은 데이터베이스 이름과 언어입니다. 이 예에서는 데이터베이스 이름을 sample-game으로 지정하고 Google 표준 SQL 언어를 선택했습니다.

스키마의 경우 이 DDL을 복사하여 상자에 붙여넣습니다.

CREATE TABLE games (
  gameUUID STRING(36) NOT NULL,
  players ARRAY<STRING(36)> NOT NULL,
  winner STRING(36),
  created TIMESTAMP,
  finished TIMESTAMP,
) PRIMARY KEY(gameUUID);

CREATE TABLE players (
  playerUUID STRING(36) NOT NULL,
  player_name STRING(64) NOT NULL,
  email STRING(MAX) NOT NULL,
  password_hash BYTES(60) NOT NULL,
  created TIMESTAMP,
  updated TIMESTAMP,
  stats JSON,
  account_balance NUMERIC NOT NULL DEFAULT (0.00),
  is_logged_in BOOL,
  last_login TIMESTAMP,
  valid_email BOOL,
  current_game STRING(36),
  FOREIGN KEY (current_game) REFERENCES games (gameUUID),
) PRIMARY KEY(playerUUID);

CREATE UNIQUE INDEX PlayerAuthentication ON players(email) STORING (password_hash);

CREATE INDEX PlayerGame ON players(current_game);

CREATE UNIQUE INDEX PlayerName ON players(player_name);

그런 다음 만들기 버튼을 클릭하고 데이터베이스가 생성될 때까지 몇 초 동안 기다립니다.

데이터베이스 만들기 페이지가 다음과 같이 표시됩니다.

d39d358dc7d32939.png

이제 Cloud Shell에서 나중에 Codelab에서 사용할 몇 가지 환경 변수를 설정해야 합니다. instance-id를 기록해 두고 Cloud Shell에서 INSTANCE_ID와 DATABASE_ID를 설정합니다.

f6f98848d3aea9c.png

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

요약

이 단계에서는 Spanner 인스턴스와 sample-game 데이터베이스를 만들었습니다. 이 샘플 게임에서 사용하는 스키마도 정의했습니다.

다음 단계

다음으로 플레이어가 가입하여 게임을 플레이할 수 있도록 프로필 서비스를 배포합니다.

4. 프로필 서비스 배포

서비스 개요

프로필 서비스는 Go로 작성된 REST API로, gin 프레임워크를 활용합니다.

4fce45ee6c858b3e.png

이 API에서 플레이어는 가입하여 게임을 플레이할 수 있습니다. 이는 플레이어 이름, 이메일 및 비밀번호를 허용하는 간단한 POST 명령으로 생성됩니다. 비밀번호는 bcrypt로 암호화되며 해시는 데이터베이스에 저장됩니다.

Email은 고유 식별자로 취급되지만 player_name은 게임의 표시 용도로 사용됩니다.

이 API는 현재 로그인을 처리하지 않지만 이 구현은 추가 연습으로 남겨둘 수 있습니다.

프로필 서비스의 ./src/golang/profile-service/main.go 파일은 다음과 같이 두 개의 기본 엔드포인트를 노출합니다.

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

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

   router.Use(setSpannerConnection(configuration))

   router.POST("/players", createPlayer)
   router.GET("/players", getPlayerUUIDs)
   router.GET("/players/:id", getPlayerByID)

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

이러한 엔드포인트의 코드는 플레이어 모델로 라우팅됩니다.

func getPlayerByID(c *gin.Context) {
   var playerUUID = c.Param("id")

   ctx, client := getSpannerConnection(c)

   player, err := models.GetPlayerByUUID(ctx, client, playerUUID)
   if err != nil {
       c.IndentedJSON(http.StatusNotFound, gin.H{"message": "player not found"})
       return
   }

   c.IndentedJSON(http.StatusOK, player)
}

func createPlayer(c *gin.Context) {
   var player models.Player

   if err := c.BindJSON(&player); err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   ctx, client := getSpannerConnection(c)
   err := player.AddPlayer(ctx, client)
   if err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   c.IndentedJSON(http.StatusCreated, player.PlayerUUID)
}

서비스가 가장 먼저 하는 작업 중 하나는 Spanner 연결을 설정하는 것입니다. 이는 서비스 수준에서 구현되어 서비스의 세션 풀을 만듭니다.

func setSpannerConnection() gin.HandlerFunc {
   ctx := context.Background()
   client, err := spanner.NewClient(ctx, configuration.Spanner.URL())

   if err != nil {
       log.Fatal(err)
   }

   return func(c *gin.Context) {
       c.Set("spanner_client", *client)
       c.Set("spanner_context", ctx)
       c.Next()
   }
}

PlayerPlayerStats는 다음과 같이 정의된 구조체입니다.

type Player struct {
   PlayerUUID      string `json:"playerUUID" validate:"omitempty,uuid4"`
   Player_name     string `json:"player_name" validate:"required_with=Password Email"`
   Email           string `json:"email" validate:"required_with=Player_name Password,email"`
   // not stored in DB
   Password        string `json:"password" validate:"required_with=Player_name Email"` 
   // stored in DB
   Password_hash   []byte `json:"password_hash"`                                       
   created         time.Time
   updated         time.Time
   Stats           spanner.NullJSON `json:"stats"`
   Account_balance big.Rat          `json:"account_balance"`
   last_login      time.Time
   is_logged_in    bool
   valid_email     bool
   Current_game    string `json:"current_game" validate:"omitempty,uuid4"`
}

type PlayerStats struct {
   Games_played spanner.NullInt64 `json:"games_played"`
   Games_won    spanner.NullInt64 `json:"games_won"`
}

플레이어 추가는 일괄 삽입이 아닌 단일 문이므로 플레이어를 추가하는 함수는 ReadWrite 트랜잭션 내의 DML 삽입을 활용합니다. 함수는 다음과 같습니다.

func (p *Player) AddPlayer(ctx context.Context, client spanner.Client) error {
   // Validate based on struct validation rules
   err := p.Validate()
   if err != nil {
       return err
   }

   // take supplied password+salt, hash. Store in user_password
   passHash, err := hashPassword(p.Password)

   if err != nil {
       return errors.New("Unable to hash password")
   }

   p.Password_hash = passHash

   // Generate UUIDv4
   p.PlayerUUID = generateUUID()

   // Initialize player stats
   emptyStats := spanner.NullJSON{Value: PlayerStats{
       Games_played: spanner.NullInt64{Int64: 0, Valid: true},
       Games_won:    spanner.NullInt64{Int64: 0, Valid: true},
   }, Valid: true}

   // insert into spanner
   _, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       stmt := spanner.Statement{
           SQL: `INSERT players (playerUUID, player_name, email, password_hash, created, stats) VALUES
                   (@playerUUID, @playerName, @email, @passwordHash, CURRENT_TIMESTAMP(), @pStats)
           `,
           Params: map[string]interface{}{
               "playerUUID":   p.PlayerUUID,
               "playerName":   p.Player_name,
               "email":        p.Email,
               "passwordHash": p.Password_hash,
               "pStats":       emptyStats,
           },
       }

       _, err := txn.Update(ctx, stmt)
       return err
   })
   if err != nil {
       return err
   }
   // return empty error on success
   return nil
}

UUID를 기반으로 플레이어를 검색하기 위해 간단한 읽기가 실행됩니다. 이 메서드는 플레이어의 playerUUID, player_name, email,stats를 검색합니다.

func GetPlayerByUUID(ctx context.Context, client spanner.Client, uuid string) (Player, error) {
   row, err := client.Single().ReadRow(ctx, "players",
       spanner.Key{uuid}, []string{"playerUUID", "player_name", "email", "stats"})
   if err != nil {
       return Player{}, err
   }

   player := Player{}
   err = row.ToStruct(&player)

   if err != nil {
       return Player{}, err
   }
   return player, nil
}

기본적으로 서비스는 환경 변수를 사용하여 구성됩니다. ./src/golang/profile-service/config/config.go 파일의 관련 섹션을 참조하세요.

func NewConfig() (Config, error) {
   *snip*
   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8080)

   // 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:8080에서 서비스를 실행하는 것입니다.

이 정보를 사용하여 서비스를 실행할 차례입니다.

프로필 서비스 실행

go 명령어를 사용하여 서비스를 실행합니다. 그러면 종속 항목이 다운로드되고 포트 8080에서 실행되는 서비스가 설정됩니다.

cd ~/spanner-gaming-sample/src/golang/profile-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] POST   /players                  --> main.createPlayer (4 handlers)
[GIN-debug] GET    /players                  --> main.getPlayerUUIDs (4 handlers)
[GIN-debug] GET    /players/:id              --> main.getPlayerByID (4 handlers)
[GIN-debug] GET    /players/:id/stats        --> main.getPlayerStats (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8080

curl 명령어를 실행하여 서비스를 테스트합니다.

curl http://localhost:8080/players \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"email": "test@gmail.com","password": "s3cur3P@ss","player_name": "Test Player"}'

명령어 결과:

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

"506a1ab6-ee5b-4882-9bb1-ef9159a72989"

요약

이 단계에서는 플레이어가 가입하여 게임을 플레이할 수 있는 프로필 서비스를 배포하고 POST API 호출을 실행하여 새 플레이어를 만들어 서비스를 테스트했습니다.

다음 단계

다음 단계에서는 랜덤 대결 서비스를 배포합니다.

5. 매칭 서비스 배포

서비스 개요

매칭 서비스는 Go로 작성된 REST API로, gin 프레임워크를 활용합니다.

9aecd571df0dcd7c.png

이 API를 사용하면 게임이 생성되고 닫힙니다. 게임이 생성되면 현재 게임을 하고 있지 않은 플레이어 10명이 게임에 할당됩니다.

게임이 종료되면 승자가 무작위로 선정되며 각 플레이어는 games_playedgames_won의 통계가 조정됩니다. 또한 각 플레이어는 더 이상 플레이하지 않으므로 향후 게임을 플레이할 수 있도록 업데이트됩니다.

무작위 대결 서비스의 ./src/golang/matchmaking-service/main.go 파일은 ./src/golang/matchmaking-service/main.go 서비스와 유사한 설정 및 코드를 따르므로 여기서는 반복하지 않습니다. 이 서비스는 다음과 같이 두 가지 기본 엔드포인트를 노출합니다.

func main() {
   router := gin.Default()
   router.SetTrustedProxies(nil)

   router.Use(setSpannerConnection())

   router.POST("/games/create", createGame)
   router.PUT("/games/close", closeGame)

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

이 서비스는 Game 구조체는 물론 축소된 PlayerPlayerStats 구조체를 제공합니다.

type Game struct {
   GameUUID string           `json:"gameUUID"`
   Players  []string         `json:"players"`
   Winner   string           `json:"winner"`
   Created  time.Time        `json:"created"`
   Finished spanner.NullTime `json:"finished"`
}

type Player struct {
   PlayerUUID   string           `json:"playerUUID"`
   Stats        spanner.NullJSON `json:"stats"`
   Current_game string           `json:"current_game"`
}

type PlayerStats struct {
   Games_played int `json:"games_played"`
   Games_won    int `json:"games_won"`
}

게임을 만들기 위해 랜덤 대결 서비스는 현재 게임을 하고 있지 않은 플레이어 100명을 무작위로 선정합니다.

대규모 변경의 경우 변형이 DML보다 성능이 우수하므로 Spanner 변형이 게임을 만들고 플레이어를 할당하기 위해 선택됩니다.

// Create a new game and assign players
// Players that are not currently playing a game are eligble to be selected for the new game
// Current implementation allows for less than numPlayers to be placed in a game
func (g *Game) CreateGame(ctx context.Context, client spanner.Client) error {
   // Initialize game values
   g.GameUUID = generateUUID()

   numPlayers := 10

   // Create and assign
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       var m []*spanner.Mutation

       // get players
       query := fmt.Sprintf("SELECT playerUUID FROM (SELECT playerUUID FROM players WHERE current_game IS NULL LIMIT 10000) TABLESAMPLE RESERVOIR (%d ROWS)", numPlayers)
       stmt := spanner.Statement{SQL: query}
       iter := txn.Query(ctx, stmt)

       playerRows, err := readRows(iter)
       if err != nil {
           return err
       }

       var playerUUIDs []string

       for _, row := range playerRows {
           var pUUID string
           if err := row.Columns(&pUUID); err != nil {
               return err
           }

           playerUUIDs = append(playerUUIDs, pUUID)
       }

       // Create the game
       gCols := []string{"gameUUID", "players", "created"}
       m = append(m, spanner.Insert("games", gCols, []interface{}{g.GameUUID, playerUUIDs, time.Now()}))

       // Update players to lock into this game
       for _, p := range playerUUIDs {
           pCols := []string{"playerUUID", "current_game"}
           m = append(m, spanner.Update("players", pCols, []interface{}{p, g.GameUUID}))
       }

       txn.BufferWrite(m)

       return nil
   })

   if err != nil {
       return err
   }

   return nil
}

플레이어의 무작위 선택은 GoogleSQL의 TABLESPACE RESERVOIR 기능을 사용하여 SQL로 이루어집니다.

게임을 종료하는 것은 약간 더 복잡합니다. 게임 플레이어 중에서 무작위로 승자를 선택하고 게임이 끝난 시간을 표시하고 각 플레이어의 games_playedgames_won의 통계를 가져옵니다.

이러한 복잡성과 변화의 양 때문에 게임을 종료하기 위해 변형을 다시 선택합니다.

func determineWinner(playerUUIDs []string) string {
   if len(playerUUIDs) == 0 {
       return ""
   }

   var winnerUUID string

   rand.Seed(time.Now().UnixNano())
   offset := rand.Intn(len(playerUUIDs))
   winnerUUID = playerUUIDs[offset]
   return winnerUUID
}

// Given a list of players and a winner's UUID, update players of a game
// Updating players involves closing out the game (current_game = NULL) and
// updating their game stats. Specifically, we are incrementing games_played.
// If the player is the determined winner, then their games_won stat is incremented.
func (g Game) updateGamePlayers(ctx context.Context, players []Player, txn *spanner.ReadWriteTransaction) error {
   for _, p := range players {
       // Modify stats
       var pStats PlayerStats
       json.Unmarshal([]byte(p.Stats.String()), &pStats)

       pStats.Games_played = pStats.Games_played + 1

       if p.PlayerUUID == g.Winner {
           pStats.Games_won = pStats.Games_won + 1
       }
       updatedStats, _ := json.Marshal(pStats)
       p.Stats.UnmarshalJSON(updatedStats)

       // Update player
       // If player's current game isn't the same as this game, that's an error
       if p.Current_game != g.GameUUID {
           errorMsg := fmt.Sprintf("Player '%s' doesn't belong to game '%s'.", p.PlayerUUID, g.GameUUID)
           return errors.New(errorMsg)
       }

       cols := []string{"playerUUID", "current_game", "stats"}
       newGame := spanner.NullString{
           StringVal: "",
           Valid:     false,
       }

       txn.BufferWrite([]*spanner.Mutation{
           spanner.Update("players", cols, []interface{}{p.PlayerUUID, newGame, p.Stats}),
       })
   }

   return nil
}

// Closing game. When provided a Game, choose a random winner and close out the game.
// A game is closed by setting the winner and finished time.
// Additionally all players' game stats are updated, and the current_game is set to null to allow
// them to be chosen for a new game.
func (g *Game) CloseGame(ctx context.Context, client spanner.Client) error {
   // Close game
   _, err := client.ReadWriteTransaction(ctx,
       func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
           // Get game players
           playerUUIDs, players, err := g.getGamePlayers(ctx, txn)

           if err != nil {
               return err
           }

           // Might be an issue if there are no players!
           if len(playerUUIDs) == 0 {
               errorMsg := fmt.Sprintf("No players found for game '%s'", g.GameUUID)
               return errors.New(errorMsg)
           }

           // Get random winner
           g.Winner = determineWinner(playerUUIDs)

           // Validate game finished time is null
           row, err := txn.ReadRow(ctx, "games", spanner.Key{g.GameUUID}, []string{"finished"})
           if err != nil {
               return err
           }

           if err := row.Column(0, &g.Finished); err != nil {
               return err
           }

           // If time is not null, then the game is already marked as finished. 
           // That's an error.
           if !g.Finished.IsNull() {
               errorMsg := fmt.Sprintf("Game '%s' is already finished.", g.GameUUID)
               return errors.New(errorMsg)
           }

           cols := []string{"gameUUID", "finished", "winner"}
           txn.BufferWrite([]*spanner.Mutation{
               spanner.Update("games", cols, []interface{}{g.GameUUID, time.Now(), g.Winner}),
           })

           // Update each player to increment stats.games_played 
           // (and stats.games_won if winner), and set current_game 
           // to null so they can be chosen for a new game
           playerErr := g.updateGamePlayers(ctx, players, txn)
           if playerErr != nil {
               return playerErr
           }

           return nil
       })

   if err != nil {
       return err
   }

   return nil
}

구성은 서비스의 ./src/golang/matchmaking-service/config/config.go에 설명된 대로 환경 변수를 통해 다시 처리됩니다.

   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8081)

   // 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")

Profile-service와의 충돌을 방지하기 위해 이 서비스는 기본적으로 localhost:8081에서 실행됩니다.

이 정보를 사용하여 이제 무작위 대결 서비스를 실행할 차례입니다.

매칭 서비스 실행

go 명령어를 사용하여 서비스를 실행합니다. 이렇게 하면 포트 8082에서 실행되는 서비스가 설정됩니다. 이 서비스에는 Profile-service와 동일한 종속 항목이 많이 있으므로 새 종속 항목이 다운로드되지 않습니다.

cd ~/spanner-gaming-sample/src/golang/matchmaking-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] POST   /games/create             --> main.createGame (4 handlers)
[GIN-debug] PUT    /games/close              --> main.closeGame (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8081

게임 만들기

서비스를 테스트하여 게임을 만듭니다. 먼저 Cloud Shell에서 새 터미널을 엽니다.

90eceac76a6bb90b.png

그런 다음 다음 curl 명령어를 실행합니다.

curl http://localhost:8081/games/create \
    --include \
    --header "Content-Type: application/json" \
    --request "POST"

명령어 결과:

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

"f45b0f7f-405b-4e67-a3b8-a624e990285d"

게임 종료

curl http://localhost:8081/games/close \
    --include \
    --header "Content-Type: application/json" \
    --data '{"gameUUID": "f45b0f7f-405b-4e67-a3b8-a624e990285d"}' \
    --request "PUT"

명령어 결과:

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: <date> 19:43:58 GMT
Content-Length: 38

"506a1ab6-ee5b-4882-9bb1-ef9159a72989"

요약

이 단계에서는 게임을 만들고 해당 게임에 플레이어를 할당하기 위해 랜덤 대결 서비스를 배포했습니다. 또한 이 서비스는 게임 종료를 처리하여 무작위로 승자를 선택하고 모든 게임 플레이어의 games_playedgames_won의 통계를 가져옵니다.

다음 단계

서비스가 실행 중이므로 이제 플레이어가 가입하고 게임을 하도록 유도할 차례입니다.

6. 재생 시작

이제 프로필 및 랜덤 대결 서비스가 실행 중이므로 제공된 locust 생성기를 사용하여 부하를 생성할 수 있습니다.

Locust는 생성기 실행을 위한 웹 인터페이스를 제공하지만 이 실습에서는 명령줄 (–headless 옵션)을 사용합니다.

플레이어 등록

먼저 플레이어를 생성해야 합니다.

./generators/authentication_server.py 파일에 플레이어를 생성하는 Python 코드는 다음과 같습니다.

class PlayerLoad(HttpUser):
   def on_start(self):
       global pUUIDs
       pUUIDs = []

   def generatePlayerName(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

   def generatePassword(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

   def generateEmail(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32) + ['@'] +
           random.choices(['gmail', 'yahoo', 'microsoft']) + ['.com'])

   @task
   def createPlayer(self):
       headers = {"Content-Type": "application/json"}
       data = {"player_name": self.generatePlayerName(), "email": self.generateEmail(), "password": self.generatePassword()}

       with self.client.post("/players", data=json.dumps(data), headers=headers, catch_response=True) as response:
           try:
               pUUIDs.append(response.json())
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'gameUUID'")

플레이어 이름, 이메일, 비밀번호가 무작위로 생성됩니다.

성공적으로 가입한 플레이어는 두 번째 작업을 통해 읽기 로드를 생성합니다.

   @task(5)
   def getPlayer(self):
       # No player UUIDs are in memory, reschedule task to run again later.
       if len(pUUIDs) == 0:
           raise RescheduleTask()

       # Get first player in our list, removing it to avoid contention from concurrent requests
       pUUID = pUUIDs[0]
       del pUUIDs[0]

       headers = {"Content-Type": "application/json"}

       self.client.get(f"/players/{pUUID}", headers=headers, name="/players/[playerUUID]")

다음 명령어는 한 번에 두 스레드의 동시 실행 (u=2)으로 30초 (t=30초) 동안 새 플레이어를 생성하는 ./generators/authentication_server.py 파일을 호출합니다.

cd ~/spanner-gaming-sample
locust -H http://127.0.0.1:8080 -f ./generators/authentication_server.py --headless -u=2 -r=2 -t=30s

플레이어가 참여하는 게임

등록되었으니 이제 게임을 플레이해 보려고 합니다.

./generators/match_server.py 파일에서 게임을 만들고 종료하는 Python 코드는 다음과 같습니다.

from locust import HttpUser, task
from locust.exception import RescheduleTask

import json

class GameMatch(HttpUser):
   def on_start(self):
       global openGames
       openGames = []

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

       # Create the game, then store the response in memory of list of open games.
       with self.client.post("/games/create", headers=headers, catch_response=True) as response:
           try:
               openGames.append({"gameUUID": response.json()})
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'gameUUID'")


   @task
   def closeGame(self):
       # No open games are in memory, reschedule task to run again later.
       if len(openGames) == 0:
           raise RescheduleTask()

       headers = {"Content-Type": "application/json"}

       # Close the first open game in our list, removing it to avoid 
       # contention from concurrent requests
       game = openGames[0]
       del openGames[0]

       data = {"gameUUID": game["gameUUID"]}
       self.client.put("/games/close", data=json.dumps(data), headers=headers)

이 생성기가 실행되면 2:1 비율 (열기:닫기)으로 게임을 열고 닫습니다. 이 명령어는 생성기를 10초 동안 실행합니다(-t=10s).

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

요약

이 단계에서는 게임 플레이에 가입하는 플레이어를 시뮬레이션한 다음 랜덤 대결 서비스를 사용하여 플레이어가 게임을 플레이할 수 있도록 시뮬레이션을 실행했습니다. 이 시뮬레이션에서는 Locust Python 프레임워크를 활용하여 Google 서비스에 대한 요청을 REST API를 사용할 수 있습니다.

플레이어를 만들고 게임을 플레이하는 데 소요된 시간은 물론 동시 사용자 수 (-u)도 자유롭게 수정할 수 있습니다.

다음 단계

시뮬레이션 후 Spanner를 쿼리하여 다양한 통계를 확인할 수 있습니다.

7. 게임 통계 가져오기

플레이어가 가입하고 게임을 즐길 수 있는 시뮬레이션을 진행했으므로 이제 통계를 확인해 보겠습니다.

이렇게 하려면 Cloud 콘솔을 사용하여 Spanner에 쿼리 요청을 실행하세요.

b5e3154c6f7cb0cf.png

공개 경기와 비공개 경기 확인하기

종료된 게임은 finished 타임스탬프가 채워진 게임이고, 열린 게임은 finished가 NULL인 게임입니다. 이 값은 게임이 종료될 때 설정됩니다.

따라서 이 쿼리를 통해 진행 중인 경기 수와 종료된 경기 수를 확인할 수 있습니다.

SELECT Type, NumGames FROM
(SELECT "Open Games" as Type, count(*) as NumGames FROM games WHERE finished IS NULL
UNION ALL
SELECT "Closed Games" as Type, count(*) as NumGames FROM games WHERE finished IS NOT NULL
)

결과:

Type

NumGames

Open Games

0

Closed Games

175

플레이하는 플레이어 수와 플레이하지 않는 플레이어의 수 확인

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

따라서 현재 게임을 하고 있는 플레이어와 게임을 하고 있지 않은 플레이어 수를 비교하려면 다음 쿼리를 사용합니다.

SELECT Type, NumPlayers FROM
(SELECT "Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NOT NULL
UNION ALL
SELECT "Not Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NULL
)

결과:

Type

NumPlayers

Playing

0

Not Playing

310

상위 수상작 결정

게임이 종료되면 플레이어 중 한 명이 무작위로 승자가 됩니다. 이 플레이어의 games_won 통계는 게임이 종료될 때 증가합니다.

SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;

결과:

playerUUID

stats

07e247c5-f88e-4bca-a7bc-12d2485f2f2b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

09b72595-40af-4406-a000-2fb56c58fe92

{&quot;games_played&quot;:56,&quot;games_won&quot;:1}

1002385b-02a0-462b-a8e7-05c9b27223aa

{&quot;games_played&quot;:66,&quot;games_won&quot;:1}

13ec3770-7ae3-495f-9b53-6322d8e8d6c3

{&quot;games_played&quot;:44,&quot;games_won&quot;:1}

15513852-3f2a-494f-b437-fe7125d15f1b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

17faec64-4f77-475c-8df8-6ab026cf6698

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

1abfcb27-037d-446d-bb7a-b5cd17b5733d

{&quot;games_played&quot;:63,&quot;games_won&quot;:1}

2109a33e-88bd-4e74-a35c-a7914d9e3bde

{&quot;games_played&quot;:56,&quot;games_won&quot;:2}

222e37d9-06b0-4674-865d-a0e5fb80121e

{&quot;games_played&quot;:60,&quot;games_won&quot;:1}

22ced15c-0da6-4fd9-8cb2-1ffd233b3c56

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

요약

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

다음 단계

이제 정리할 시간입니다.

8. 삭제 (선택사항)

삭제하려면 Cloud 콘솔의 Cloud Spanner 섹션으로 이동하여 Codelab 단계에서 'Cloud Spanner 인스턴스 설정'이라는 이름의 'cloudspanner-gaming' 인스턴스를 삭제하면 됩니다.

9. 축하합니다.

수고하셨습니다. Spanner에 샘플 게임을 배포했습니다.

다음 단계

이 실습에서는 golang 드라이버를 사용하여 Spanner로 작업하는 다양한 주제를 배웠습니다. 이렇게 하면 다음과 같은 중요한 개념을 더 잘 이해할 수 있습니다.

  • 스키마 설계
  • DML과 변형 비교
  • Golang 사용하기

게임의 백엔드로 Spanner를 사용하는 또 다른 예는 Cloud Spanner 게임 트레이딩 게시물 Codelab을 참조하세요.