Cloud Spanner Начало работы с разработкой игр

1. Введение

Cloud Spanner — это полностью управляемый, горизонтально масштабируемый, глобально распределенный сервис реляционных баз данных, обеспечивающий ACID-транзакции и семантику SQL без ущерба для производительности и высокой доступности.

Благодаря этим особенностям Spanner отлично подходит для архитектуры игр, которые стремятся охватить глобальную базу игроков или обеспокоены согласованностью данных.

В этой лабораторной работе вы создадите два сервиса на языке Go , которые будут взаимодействовать с региональной базой данных Spanner, чтобы игроки могли регистрироваться и начинать играть.

413fdd57bb0b68bc.png

Далее вы сгенерируете данные, используя фреймворк загрузки Python Locust.io , чтобы смоделировать регистрацию и игру игроков. Затем вы отправите запрос в Spanner, чтобы определить количество игроков и получить статистику о количестве выигранных и сыгранных игр.

Наконец, вам предстоит очистить ресурсы, созданные в этой лабораторной работе.

Что вы построите

В рамках этой лабораторной работы вы:

  • Создайте экземпляр Spanner.
  • Разверните службу профилей, написанную на Go, для обработки регистрации игроков.
  • Разверните сервис подбора игроков, написанный на Go, для распределения игроков по играм, определения победителей и обновления игровой статистики игроков.

Что вы узнаете

  • Как настроить экземпляр Cloud Spanner
  • Как создать базу данных и схему для игры
  • Как развернуть приложения Go для работы с Cloud Spanner
  • Как генерировать данные с помощью Locust
  • Как запрашивать данные в Cloud Spanner, чтобы получать ответы на вопросы об играх и игроках.

Что вам понадобится

  • Проект Google Cloud, подключенный к платежному аккаунту.
  • Веб-браузер, например Chrome или Firefox .

2. Настройка и требования

Создать проект

Если у вас еще нет учетной записи Google (Gmail или Google Apps), вам необходимо ее создать . Войдите в консоль Google Cloud Platform ( console.cloud.google.com ) и создайте новый проект.

Если у вас уже есть проект, щелкните раскрывающееся меню выбора проекта в левом верхнем углу консоли:

6c9406d9b014760.png

и нажмите кнопку «СОЗДАТЬ ПРОЕКТ» в появившемся диалоговом окне, чтобы создать новый проект:

949d83c8a4ee17d9.png

Если у вас ещё нет проекта, вы увидите диалоговое окно, подобное этому, для создания вашего первого проекта:

870a3cbd6541ee86.png

В появившемся диалоговом окне создания проекта вы можете ввести подробные сведения о вашем новом проекте:

6a92c57d3250a4b3.png

Запомните идентификатор проекта (Project ID), который является уникальным именем для всех проектов Google Cloud (указанное выше имя уже занято и вам не подойдёт, извините!). В дальнейшем в этом практическом занятии он будет обозначаться как PROJECT_ID.

Далее, если вы еще этого не сделали, вам необходимо включить оплату в консоли разработчика, чтобы использовать ресурсы Google Cloud и активировать API Cloud Spanner .

15d0ef27a8fbab27.png

Выполнение этого практического задания не должно обойтись вам дороже нескольких долларов, но может стоить больше, если вы решите использовать больше ресурсов или оставите их запущенными (см. раздел «очистка» в конце этого документа). Информация о ценах на Google Cloud Spanner приведена здесь .

Новые пользователи Google Cloud Platform могут воспользоваться бесплатной пробной версией стоимостью 300 долларов , что сделает этот практический семинар совершенно бесплатным.

Настройка Google Cloud Shell

Хотя Google Cloud и Spanner можно запускать удаленно с ноутбука, в этом практическом занятии мы будем использовать Google Cloud Shell — среду командной строки, работающую в облаке.

Эта виртуальная машина на базе Debian содержит все необходимые инструменты разработки. Она предоставляет постоянный домашний каталог размером 5 ГБ и работает в облаке Google, что значительно повышает производительность сети и аутентификацию. Это означает, что для выполнения этого практического задания вам понадобится только браузер (да, он работает и на Chromebook).

  1. Для активации Cloud Shell из консоли Cloud Console просто нажмите «Активировать Cloud Shell». gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (На подготовку и подключение к среде должно уйти всего несколько минут).

JjEuRXGg0AYYIY6QZ8d-66gx_Mtc-_jDE9ijmbXLJSAXFvJt-qUpNtsBsYjNpv2W6BQSr Dc1D-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 — это фреймворк для нагрузочного тестирования на Python, полезный для тестирования REST API-интерфейсов. В этом практическом задании мы рассмотрим два разных нагрузочных теста в каталоге 'generators':

  • authentication_server.py : содержит задачи для создания игроков и для выбора случайного игрока, имитирующего поиск по одной точке.
  • Файл match_server.py содержит задачи для создания и закрытия игр. При создании игр будут назначены 100 случайных игроков, которые в данный момент не играют. При закрытии игр будут обновлены статистики games_played и games_won, и эти игроки смогут быть назначены на будущую игру.

Для запуска Locust в Cloud Shell вам потребуется 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

Теперь обновите переменную PATH, чтобы можно было найти только что установленный исполняемый файл locust :

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

вывод команды

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

Краткое содержание

На этом этапе вы создали свой проект, если у вас его еще не было, активировали Cloud Shell и загрузили код для этой лабораторной работы.

Наконец, в ходе лабораторной работы вы настроите Locust для генерации нагрузки.

Далее

Далее вам предстоит настроить экземпляр Cloud Spanner и базу данных.

3. Создайте экземпляр Spanner и базу данных.

Создайте экземпляр Spanner.

На этом шаге мы настраиваем экземпляр Spanner для выполнения практического задания. Найдите запись Spanner. 1a6580bd3d3e6783.png в левом верхнем углу меню гамбургеров 3129589f7bc9e5ce.png или найдите «Spanner», нажав клавишу «/» и введя «Spanner».

36e52f8df8e13b99.png

Далее нажмите на 95269e75bc8c3e4d.png Заполните форму, указав имя экземпляра cloudspanner-gaming для вашего экземпляра, выбрав конфигурацию (например, региональный экземпляр us-central1 ) и указав количество узлов. Для этого практического занятия нам потребуется всего 500 processing units .

И наконец, нажмите кнопку «Создать», и через несколько секунд у вас будет в распоряжении экземпляр Cloud Spanner.

4457c324c94f93e6.png

Создайте базу данных и схему.

После запуска экземпляра вы можете создать базу данных. Spanner позволяет использовать несколько баз данных на одном экземпляре.

В базе данных вы определяете свою схему. Вы также можете контролировать доступ к базе данных, настраивать пользовательское шифрование, конфигурировать оптимизатор и устанавливать период хранения.

В многорегиональных экземплярах также можно настроить лидера по умолчанию. Подробнее о базах данных в Spanner можно прочитать здесь .

В рамках этой практической работы вам предстоит создать базу данных с параметрами по умолчанию и указать схему при её создании.

В ходе этой лабораторной работы будут созданы две таблицы: игроки и игры .

77651ac12e47fe2a.png

Игроки могут участвовать во многих играх в течение определенного времени, но только в одной игре за раз. У игроков также есть статистика в формате JSON для отслеживания интересных статистических данных, таких как количество сыгранных игр и количество выигранных игр . Поскольку другие статистические данные могут быть добавлены позже, это фактически столбец без схемы для игроков.

В играх отслеживание участников осуществляется с помощью типа данных ARRAY в Spanner. Атрибуты победителя и завершения игры заполняются только после её завершения.

Существует один внешний ключ, гарантирующий, что текущая игра игрока (current_game) является допустимой игрой.

Теперь создайте базу данных, нажав кнопку «Создать базу данных» в обзоре экземпляра:

a820db6c4a4d6f2d.png

Затем заполните подробности. Важные параметры — это имя базы данных и диалект. В этом примере мы назвали базу данных sample-game и выбрали диалект Google Standard 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, которые будут использоваться позже в практическом задании. Запишите идентификатор экземпляра (instance-id) и установите значения INSTANCE_ID и DATABASE_ID в Cloud Shell.

f6f98848d3aea9c.png

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

Краткое содержание

На этом шаге вы создали экземпляр Spanner и базу данных для демонстрационной игры . Вы также определили схему, используемую в этой демонстрационной игре.

Далее

Далее вам потребуется развернуть службу профилей, чтобы игроки могли регистрироваться для участия в игре!

4. Разверните службу профилирования.

Обзор услуг

Сервис профилирования представляет собой REST API, написанный на Go и использующий фреймворк Gin.

4fce45ee6c858b3e.png

В этом API игроки могут зарегистрироваться для участия в играх. Регистрация осуществляется с помощью простой команды POST, которая принимает имя игрока, адрес электронной почты и пароль. Пароль шифруется с помощью bcrypt , а хеш хранится в базе данных.

Адрес электронной почты рассматривается как уникальный идентификатор, а имя игрока используется для отображения в игре.

В настоящее время этот 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()
   }
}

Структуры Player и PlayerStats определены следующим образом:

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"`
}

Функция добавления игрока использует операцию вставки 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. Внедрить сервис подбора партнеров.

Обзор услуг

Сервис подбора партнеров представляет собой REST API, написанный на Go и использующий фреймворк gin.

9aecd571df0dcd7c.png

В этом API создаются и закрываются игры. При создании игры к ней добавляются 10 игроков, которые в данный момент не участвуют в игре.

Когда игра завершается , победитель выбирается случайным образом, и статистика каждого игрока по количеству сыгранных и выигранных игр корректируется. Кроме того, для каждого игрока обновляется информация о том, что он больше не играет и, следовательно, доступен для участия в будущих играх.

Файл ./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 , а также упрощенные структуры Player и PlayerStats :

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 игроков, которые в данный момент не играют ни в одну игру.

Для создания игры и распределения игроков используются мутации типа Spanner , поскольку мутации более производительны, чем операции DML, при больших изменениях.

// 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
}

Случайный выбор игроков осуществляется с помощью SQL, используя возможности TABLESPACE RESERVOIR в GoogleSQL.

Завершение игры несколько сложнее. Оно включает в себя выбор случайного победителя среди игроков, отметку времени окончания игры и обновление статистики каждого игрока по полям games_played и games_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")

Во избежание конфликтов со службой профилирования, эта служба по умолчанию работает на 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_played и games_won .

Следующие шаги

Теперь, когда ваши сервисы запущены, пришло время привлечь игроков к регистрации и игре!

6. Начните играть

Теперь, когда запущены службы профилирования и подбора партнеров, вы можете генерировать нагрузку, используя предоставленные генераторы Locust.

Locust предоставляет веб-интерфейс для запуска генераторов, но в этой лабораторной работе вы будете использовать командную строку (опция –headless ).

Зарегистрируйте игроков

Для начала вам нужно будет создать игроков.

Код Python для создания игроков в файле ./generators/authentication_server.py выглядит следующим образом:

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

Следующая команда вызывает файл ./generators/authentication_server.py , который будет генерировать новых игроков в течение 30 секунд ( t=30s) с одновременным выполнением двух потоков (u=2):

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

Игроки присоединяются к играм

Теперь, когда у вас есть зарегистрированные игроки, они хотят начать играть в игры!

Код Python для создания и закрытия игр в файле ./generators/match_server.py выглядит следующим образом:

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 для отправки запросов к REST API наших сервисов.

Вы можете изменить время, затрачиваемое на создание игроков и запуск игр, а также количество одновременно играющих пользователей ( -u) .

Следующие шаги

После завершения моделирования вам потребуется проверить различные статистические данные, отправив запрос в Spanner.

7. Получение игровой статистики

Теперь, когда у нас есть возможность имитировать игроков, регистрирующихся и играющих в игры, вам следует проверить свою статистику.

Для этого используйте Cloud Console для отправки запросов в Spanner.

b5e3154c6f7cb0cf.png

Проверка открытых и закрытых игр

Закрытая игра — это игра, в которой заполнена метка времени завершения , тогда как в открытой игре значение параметра «завершено» будет равно 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

статистика

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

{"games_played":49,"games_won":1}

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

{"games_played":56,"games_won":1}

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

{"games_played":66,"games_won":1}

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

{"games_played":44,"games_won":1}

15513852-3f2a-494f-b437-fe7125d15f1b

{"games_played":49,"games_won":1}

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

{"games_played":50,"games_won":1}

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

{"games_played":63,"games_won":1}

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

{"games_played":56,"games_won":2}

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

{"games_played":60,"games_won":1}

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

{"games_played":50,"games_won":1}

Краткое содержание

На этом этапе вы просмотрели различную статистику игроков и игр, используя облачную консоль для запроса данных в Spanner.

Следующие шаги

Далее, пришло время уборки!

8. Уборка (необязательно)

Для очистки просто перейдите в раздел Cloud Spanner в Cloud Console и удалите экземпляр 'cloudspanner-gaming', созданный нами на шаге практического задания под названием "Настройка экземпляра Cloud Spanner".

9. Поздравляем!

Поздравляем, вы успешно развернули тестовую игру на Spanner.

Что дальше?

В этой лабораторной работе вы познакомились с различными аспектами работы со Spanner с использованием драйвера Golang. Это должно дать вам более прочную основу для понимания таких важных концепций, как:

  • проектирование схемы
  • DML против мутаций
  • Работа с Golang

Обязательно ознакомьтесь с практической работой по использованию Cloud Spanner Game Trading Post, чтобы увидеть еще один пример работы со Spanner в качестве бэкэнда для вашей игры!