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 ) и создайте новый проект.
Если у вас уже есть проект, щелкните раскрывающееся меню выбора проекта в левом верхнем углу консоли:
и нажмите кнопку «НОВЫЙ ПРОЕКТ» в появившемся диалоговом окне, чтобы создать новый проект:
Если у вас еще нет проекта, вы должны увидеть подобное диалоговое окно, чтобы создать свой первый:
Последующий диалог создания проекта позволяет вам ввести детали вашего нового проекта:
Запомните идентификатор проекта, который является уникальным именем для всех проектов Google Cloud (имя, указанное выше, уже занято и не подойдет вам, извините!). Позже в этой лаборатории он будет называться PROJECT_ID.
Далее, если вы еще этого не сделали, вам необходимо включить выставление счетов в консоли разработчика, чтобы использовать ресурсы Google Cloud и включить Cloud Spanner API .
Выполнение этой кодовой лаборатории не должно стоить вам больше нескольких долларов, но может стоить больше, если вы решите использовать больше ресурсов или оставите их включенными (см. раздел «Очистка» в конце этого документа). Цены на Google Cloud Spanner указаны здесь .
Новые пользователи Google Cloud Platform имеют право на бесплатную пробную версию за 300 долларов США , что делает эту лабораторию кода совершенно бесплатной.
Настройка Google Cloud Shell
Хотя Google Cloud и Spanner можно управлять удаленно с вашего ноутбука, в этой лаборатории мы будем использовать Google Cloud Shell , среду командной строки, работающую в облаке.
Эта виртуальная машина на базе Debian оснащена всеми необходимыми инструментами разработки. Он предлагает постоянный домашний каталог объемом 5 ГБ и работает в Google Cloud, что значительно повышает производительность сети и аутентификацию. Это означает, что все, что вам понадобится для этой лаборатории кода, — это браузер (да, он работает на 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? Узнайте, какой идентификатор вы использовали на этапах настройки, или найдите его на панели управления 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. В этой кодовой лаборатории у нас есть два разных нагрузочных теста в каталоге «генераторы», которые мы выделим:
- 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
Краткое содержание
На этом этапе вы настроили свой проект, если у вас его еще нет, активировали облачную оболочку и загрузили код для этой лабораторной работы.
Наконец, позже в лабораторной работе вы настроите Locust для генерации нагрузки.
Дальше
Далее вы настроите экземпляр Cloud Spanner и базу данных.
3. Создайте экземпляр и базу данных Spanner.
Создайте экземпляр Spanner
На этом этапе мы настраиваем наш экземпляр Spanner для лаборатории кода. Найдите запись о гаечном ключе в левом верхнем меню гамбургеров или найдите «Гаечный ключ», нажав «/» и введите «Гаечный ключ».
Далее нажмите и заполните форму, введя имя экземпляра cloudspanner-gaming
для вашего экземпляра, выбрав конфигурацию (выберите региональный экземпляр, например us-central1
), и задайте количество узлов. Для этой кодовой лаборатории нам понадобится всего 500 processing units
.
И последнее, но не менее важное: нажмите «Создать», и через несколько секунд в вашем распоряжении будет экземпляр Cloud Spanner.
Создайте базу данных и схему
После запуска экземпляра вы можете создать базу данных. Spanner позволяет использовать несколько баз данных в одном экземпляре.
База данных — это место, где вы определяете свою схему. Вы также можете контролировать, кто имеет доступ к базе данных, настраивать собственное шифрование, настраивать оптимизатор и устанавливать период хранения.
В экземплярах с несколькими регионами вы также можете настроить лидера по умолчанию. Узнайте больше о базах данных на Spanner.
Для этой лаборатории кода вы создадите базу данных с параметрами по умолчанию и предоставите схему во время создания.
В ходе этой лабораторной работы будут созданы две таблицы: Players и Games .
Игроки могут участвовать во многих играх с течением времени, но одновременно только в одной игре. У игроков также есть статистика в формате данных JSON , чтобы отслеживать интересную статистику, например games_played и games_won . Поскольку позже могут быть добавлены другие статистические данные, для игроков это фактически бессхемный столбец.
Игры отслеживают участников, используя тип данных 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 и 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 внутри транзакции ReadWrite, поскольку добавление игроков представляет собой один оператор, а не пакетную вставку. Функция выглядит следующим образом:
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, адрес электронной почты и статистика .
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
}
Вы можете видеть, что по умолчанию служба запускается на локальном хосте: 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"
Краткое содержание
На этом этапе вы развернули службу профилей, которая позволяет игрокам регистрироваться для игры в вашу игру, и протестировали службу, выполнив вызов API POST для создания нового игрока.
Следующие шаги
На следующем этапе вы развернете службу подбора игроков.
5. Разверните службу подбора игроков
Обзор услуг
Служба подбора партнеров — это REST API, написанный на Go и использующий фреймворк gin.
В этом API создаются и закрываются игры. При создании игры к ней назначаются 10 игроков, которые в данный момент не играют в игру.
Когда игра закрывается , победитель выбирается случайным образом, и статистика каждого игрока для games_played и games_won корректируется. Кроме того, каждый игрок обновляется, чтобы указать, что он больше не играет и поэтому готов играть в будущие игры.
Файл ./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 игроков, которые в данный момент не играют в игру.
Ключевые мутации выбираются для создания игры и назначения игроков, поскольку мутации более эффективны, чем 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")
Чтобы избежать конфликтов со службой профилей, эта служба по умолчанию запускается на локальном хосте: 8081 .
Имея эту информацию, пришло время запустить службу подбора игроков.
Запустите службу поиска партнеров
Запустите службу с помощью команды go. Это установит службу, работающую на порту 8082. Эта служба имеет многие из тех же зависимостей, что и служба профиля, поэтому новые зависимости не будут загружены.
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 предлагает веб-интерфейс для запуска генераторов, но в этой лабораторной работе вы будете использовать командную строку (опция –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=30 секунд) с одновременным параллелизмом двух потоков (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=10 с) :
locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s
Краткое содержание
На этом этапе вы смоделировали регистрацию игроков для участия в играх, а затем запустили симуляцию, позволяющую игрокам играть в игры, с помощью службы подбора игроков. В этих симуляциях использовалась платформа Locust Python для отправки запросов к API REST наших сервисов.
Не стесняйтесь изменять время, затрачиваемое на создание игроков и игру в игры, а также количество одновременных пользователей ( -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;
Результат:
UUID игрока | статистика |
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} |
Краткое содержание
На этом этапе вы просмотрели различную статистику игроков и игр, используя Cloud Console для запроса Spanner.
Следующие шаги
Далее пришло время навести порядок!
8. Уборка (по желанию)
Чтобы выполнить очистку, просто зайдите в раздел Cloud Spanner в облачной консоли и удалите экземпляр Cloud Spanner-gaming, который мы создали на этапе работы с кодом под названием «Настройка экземпляра Cloud Spanner».
9. Поздравляем!
Поздравляем, вы успешно развернули образец игры на Spanner.
Что дальше?
В ходе этой лабораторной работы вы познакомились с различными темами работы с Spanner с использованием драйвера golang. Это должно дать вам лучшую основу для понимания таких важных понятий, как:
- Проектирование схемы
- DML против мутаций
- Работа с Голангом
Обязательно ознакомьтесь с кодовой лабораторией Cloud Spanner Game Trading Post, чтобы увидеть еще один пример работы с Spanner в качестве бэкэнда для вашей игры!