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

Далее вы сгенерируете данные, используя фреймворк загрузки Python Locust.io , чтобы смоделировать регистрацию и игру игроков. Затем вы отправите запрос в Spanner, чтобы определить количество игроков и получить статистику о количестве выигранных и сыгранных игр.
Наконец, вам предстоит очистить ресурсы, созданные в этой лабораторной работе.
Что вы построите
В рамках этой лабораторной работы вы:
- Создайте экземпляр Spanner.
- Разверните службу профилей, написанную на Go, для обработки регистрации игроков.
- Разверните сервис подбора игроков, написанный на Go, для распределения игроков по играм, определения победителей и обновления игровой статистики игроков.
Что вы узнаете
- Как настроить экземпляр Cloud Spanner
- Как создать базу данных и схему для игры
- Как развернуть приложения Go для работы с Cloud Spanner
- Как генерировать данные с помощью Locust
- Как запрашивать данные в Cloud Spanner, чтобы получать ответы на вопросы об играх и игроках.
Что вам понадобится
2. Настройка и требования
Создать проект
Если у вас еще нет учетной записи Google (Gmail или Google Apps), вам необходимо ее создать . Войдите в консоль Google Cloud Platform ( console.cloud.google.com ) и создайте новый проект.
Если у вас уже есть проект, щелкните раскрывающееся меню выбора проекта в левом верхнем углу консоли:

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

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

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

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

Выполнение этого практического задания не должно обойтись вам дороже нескольких долларов, но может стоить больше, если вы решите использовать больше ресурсов или оставите их запущенными (см. раздел «очистка» в конце этого документа). Информация о ценах на Google Cloud Spanner приведена здесь .
Новые пользователи Google Cloud Platform могут воспользоваться бесплатной пробной версией стоимостью 300 долларов , что сделает этот практический семинар совершенно бесплатным.
Настройка Google Cloud Shell
Хотя Google Cloud и Spanner можно запускать удаленно с ноутбука, в этом практическом занятии мы будем использовать Google Cloud Shell — среду командной строки, работающую в облаке.
Эта виртуальная машина на базе Debian содержит все необходимые инструменты разработки. Она предоставляет постоянный домашний каталог размером 5 ГБ и работает в облаке Google, что значительно повышает производительность сети и аутентификацию. Это означает, что для выполнения этого практического задания вам понадобится только браузер (да, он работает и на Chromebook).
- Для активации Cloud Shell из консоли Cloud Console просто нажмите «Активировать Cloud Shell».
(На подготовку и подключение к среде должно уйти всего несколько минут).


После подключения к 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:

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.
в левом верхнем углу меню гамбургеров
или найдите «Spanner», нажав клавишу «/» и введя «Spanner».

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

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

Игроки могут участвовать во многих играх в течение определенного времени, но только в одной игре за раз. У игроков также есть статистика в формате JSON для отслеживания интересных статистических данных, таких как количество сыгранных игр и количество выигранных игр . Поскольку другие статистические данные могут быть добавлены позже, это фактически столбец без схемы для игроков.
В играх отслеживание участников осуществляется с помощью типа данных ARRAY в Spanner. Атрибуты победителя и завершения игры заполняются только после её завершения.
Существует один внешний ключ, гарантирующий, что текущая игра игрока (current_game) является допустимой игрой.
Теперь создайте базу данных, нажав кнопку «Создать базу данных» в обзоре экземпляра:

Затем заполните подробности. Важные параметры — это имя базы данных и диалект. В этом примере мы назвали базу данных 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);
Затем нажмите кнопку «Создать» и подождите несколько секунд, пока будет создана ваша база данных.
Страница создания базы данных должна выглядеть следующим образом:

Теперь вам нужно установить несколько переменных окружения в Cloud Shell, которые будут использоваться позже в практическом задании. Запишите идентификатор экземпляра (instance-id) и установите значения INSTANCE_ID и DATABASE_ID в Cloud Shell.

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.

В этом 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.

В этом 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:

Затем выполните следующую команду 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.

Проверка открытых и закрытых игр
Закрытая игра — это игра, в которой заполнена метка времени завершения , тогда как в открытой игре значение параметра «завершено» будет равно 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
)
Результат:
| |
| |
| |
Проверка количества игроков, участвующих в игре, и количества игроков, не участвующих в ней.
Игрок играет в игру, если в столбце 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
)
Результат:
| |
| |
| |
Определить главных победителей
Когда игра завершается, один из игроков случайным образом выбирается в качестве победителя. Статистика 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 в качестве бэкэнда для вашей игры!