۱. مقدمه
کلود اسپنر یک سرویس پایگاه داده رابطهای، توزیعشده در سطح جهانی و مقیاسپذیر افقی است که تراکنشهای ACID و مفاهیم SQL را بدون از دست دادن عملکرد و دسترسیپذیری بالا ارائه میدهد.
این ویژگیها، اسپنر را به گزینهای عالی برای معماری بازیهایی تبدیل میکند که میخواهند پایگاه بازیکنان جهانی داشته باشند یا نگران ثبات دادهها هستند.
در این آزمایش، شما دو سرویس Go ایجاد خواهید کرد که با یک پایگاه داده منطقهای Spanner تعامل دارند تا بازیکنان بتوانند ثبت نام کرده و بازی را شروع کنند.

در مرحله بعد، با استفاده از چارچوب بارگذاری پایتون Locust.io، دادهها را برای شبیهسازی ثبتنام و انجام بازی توسط بازیکنان تولید خواهید کرد. سپس از Spanner درخواست خواهید کرد تا تعداد بازیکنان در حال بازی را تعیین کند و آماری در مورد تعداد بازیهای برنده شده بازیکنان در مقابل تعداد بازیهای انجام شده ارائه دهد.
در نهایت، منابعی را که در این آزمایشگاه ایجاد شدهاند، پاکسازی خواهید کرد.
آنچه خواهید ساخت
به عنوان بخشی از این آزمایشگاه، شما:
- یک نمونه Spanner ایجاد کنید
- یک سرویس پروفایل نوشته شده در Go برای مدیریت ثبت نام بازیکن مستقر کنید
- یک سرویس Matchmaking نوشته شده با زبان Go را برای اختصاص دادن بازیکنان به بازیها، تعیین برندگان و بهروزرسانی آمار بازی بازیکنان، مستقر کنید.
آنچه یاد خواهید گرفت
- نحوه راهاندازی یک نمونه Cloud Spanner
- نحوه ایجاد پایگاه داده و طرحواره بازی
- نحوهی پیادهسازی برنامههای Go برای کار با Cloud Spanner
- نحوه تولید داده با استفاده از Locust
- نحوه پرس و جو از دادهها در Cloud Spanner برای پاسخ به سوالات مربوط به بازیها و بازیکنان.
آنچه نیاز دارید
۲. تنظیمات و الزامات
ایجاد یک پروژه
اگر از قبل حساب گوگل (جیمیل یا برنامههای گوگل) ندارید، باید یکی ایجاد کنید . وارد کنسول پلتفرم ابری گوگل ( console.cloud.google.com ) شوید و یک پروژه جدید ایجاد کنید.
اگر از قبل پروژهای دارید، روی منوی کشویی انتخاب پروژه در سمت چپ بالای کنسول کلیک کنید:

و در پنجرهی باز شده روی دکمهی «پروژهی جدید» کلیک کنید تا یک پروژهی جدید ایجاد شود:

اگر از قبل پروژهای ندارید، باید پنجرهای مانند این را برای ایجاد اولین پروژه خود ببینید:

پنجرهی بعدیِ ایجاد پروژه به شما امکان میدهد جزئیات پروژهی جدید خود را وارد کنید:

شناسه پروژه را به خاطر بسپارید، که یک نام منحصر به فرد در تمام پروژههای Google Cloud است (نام بالا قبلاً گرفته شده و برای شما کار نخواهد کرد، متاسفیم!). بعداً در این آزمایشگاه کد به آن PROJECT_ID گفته خواهد شد.
در مرحله بعد، اگر قبلاً این کار را نکردهاید، برای استفاده از منابع Google Cloud و فعال کردن Cloud Spanner API ، باید صورتحساب را در کنسول توسعهدهندگان فعال کنید .

اجرای این آزمایشگاه کد نباید بیش از چند دلار برای شما هزینه داشته باشد، اما اگر تصمیم به استفاده از منابع بیشتر بگیرید یا اگر آنها را در حال اجرا رها کنید (به بخش «پاکسازی» در انتهای این سند مراجعه کنید)، میتواند بیشتر هم بشود. قیمت Google Cloud Spanner در اینجا مستند شده است.
کاربران جدید پلتفرم ابری گوگل واجد شرایط دریافت یک دوره آزمایشی رایگان ۳۰۰ دلاری هستند که این کدلب را کاملاً رایگان میکند.
راهاندازی پوسته ابری گوگل
اگرچه میتوان از راه دور و از طریق لپتاپ، گوگل کلود و اسپنر را کنترل کرد، اما در این آزمایشگاه کد، از گوگل کلود شل ، یک محیط خط فرمان که در فضای ابری اجرا میشود، استفاده خواهیم کرد.
این ماشین مجازی مبتنی بر دبیان، تمام ابزارهای توسعه مورد نیاز شما را در خود جای داده است. این ماشین مجازی یک دایرکتوری خانگی ۵ گیگابایتی دائمی ارائه میدهد و در فضای ابری گوگل اجرا میشود که عملکرد شبکه و احراز هویت را تا حد زیادی بهبود میبخشد. این بدان معناست که تنها چیزی که برای این آزمایشگاه کد نیاز دارید یک مرورگر است (بله، روی کرومبوک هم کار میکند).
- برای فعال کردن Cloud Shell از کنسول Cloud، کافیست روی Activate 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 یک چارچوب تست بار پایتون است که برای آزمایش نقاط پایانی REST API مفید است. در این آزمایشگاه کد، ما دو تست بار مختلف در دایرکتوری 'generators' داریم که به آنها اشاره خواهیم کرد:
- authentication_server.py : شامل وظایفی برای ایجاد بازیکنان و دریافت یک بازیکن تصادفی برای تقلید از جستجوی تک نقطهای است.
- match_server.py : شامل وظایفی برای ایجاد بازیها و بستن بازیها است. ایجاد بازیها ۱۰۰ بازیکن تصادفی را که در حال حاضر بازی نمیکنند، اختصاص میدهد. بستن بازیها، آمار games_played و games_won را بهروزرسانی میکند و به آن بازیکنان اجازه میدهد تا به یک بازی آینده اختصاص داده شوند.
برای اجرای Locust در Cloud Shell، به پایتون ۳.۷ یا بالاتر نیاز دارید. Cloud Shell با پایتون ۳.۹ ارائه میشود، بنابراین کاری جز اعتبارسنجی نسخه وجود ندارد:
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 را بهروزرسانی کنید تا فایل باینری تازه نصب شدهی ملخ پیدا شود:
PATH=~/.local/bin":$PATH"
which locust
خروجی دستور
/home/<user>/.local/bin/locust
خلاصه
در این مرحله، اگر پروژهای نداشتید، آن را راهاندازی کردهاید، پوسته ابری را فعال کردهاید و کد مربوط به این آزمایش را دانلود کردهاید.
در نهایت، شما Locust را برای تولید بار در ادامهی آزمایشگاه تنظیم میکنید.
بعدی
در مرحله بعد، نمونه و پایگاه داده Cloud Spanner را تنظیم خواهید کرد.
۳. یک نمونه و پایگاه داده Spanner ایجاد کنید
نمونه Spanner را ایجاد کنید
در این مرحله، نمونه Spanner خود را برای codelab تنظیم میکنیم. ورودی Spanner را جستجو کنید.
در منوی همبرگری بالا سمت چپ
یا با فشردن کلید "/" و تایپ "Spanner" عبارت Spanner را جستجو کنید.

در مرحله بعد، روی کلیک کنید
و فرم را با وارد کردن نام نمونه cloudspanner-gaming برای نمونه خود، انتخاب پیکربندی (یک نمونه منطقهای مانند us-central1 را انتخاب کنید) و تنظیم تعداد گرهها پر کنید. برای این آزمایشگاه کد، ما فقط 500 processing units نیاز داریم.
در آخر، اما نه کماهمیتتر، روی «ایجاد» کلیک کنید و ظرف چند ثانیه یک نمونه Cloud Spanner در اختیار شما قرار میگیرد.

ایجاد پایگاه داده و طرحواره
پس از اجرای نمونه، میتوانید پایگاه داده را ایجاد کنید. Spanner امکان ایجاد چندین پایگاه داده را در یک نمونه واحد فراهم میکند.
پایگاه داده جایی است که شما طرحواره خود را تعریف میکنید. همچنین میتوانید کنترل کنید چه کسی به پایگاه داده دسترسی داشته باشد، رمزگذاری سفارشی تنظیم کنید، بهینهساز را پیکربندی کنید و مدت زمان نگهداری را تعیین کنید.
در نمونههای چند منطقهای، میتوانید رهبر پیشفرض را نیز پیکربندی کنید. درباره پایگاههای داده در Spanner بیشتر بخوانید .
برای این آزمایشگاه کد، پایگاه داده را با گزینههای پیشفرض ایجاد خواهید کرد و طرحواره را در زمان ایجاد ارائه خواهید داد.
این آزمایشگاه دو جدول ایجاد خواهد کرد: بازیکنان و بازیها .

بازیکنان میتوانند در طول زمان در بازیهای زیادی شرکت کنند، اما فقط در یک بازی در هر زمان. بازیکنان همچنین آمار خود را به صورت یک نوع داده JSON دارند تا آمار جالب مانند games_played و games_won را پیگیری کنند. از آنجا که ممکن است آمار دیگری بعداً اضافه شود، این ستون عملاً برای بازیکنان بدون طرح است.
بازیها با استفاده از نوع داده ARRAY در Spanner، تعداد بازیکنان شرکتکننده را ثبت میکنند. ویژگیهای برنده و پایان بازی تا زمان بسته شدن بازی، پر نمیشوند.
یک کلید خارجی وجود دارد تا اطمینان حاصل شود که current_game بازیکن، یک بازی معتبر است.
اکنون با کلیک بر روی «ایجاد پایگاه داده» در نمای کلی نمونه، پایگاه داده را ایجاد کنید:

و سپس جزئیات را پر کنید. گزینههای مهم، نام پایگاه داده و گویش آن هستند. در این مثال، ما پایگاه داده را sample-game نامگذاری کردیم و گویش استاندارد 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 و پایگاه داده بازی نمونه ایجاد کردید. همچنین طرحوارهای را که این بازی نمونه از آن استفاده میکند، تعریف کردهاید.
بعدی
در مرحله بعد، سرویس پروفایل را مستقر خواهید کرد تا به بازیکنان اجازه دهید برای انجام بازی ثبت نام کنند!
۴. سرویس پروفایل را مستقر کنید
نمای کلی خدمات
سرویس پروفایل یک API REST است که با زبان Go نوشته شده و از فریمورک gin بهره میبرد.

در این API، بازیکنان میتوانند برای بازی کردن ثبتنام کنند. این کار با یک دستور POST ساده انجام میشود که نام، ایمیل و رمز عبور بازیکن را میپذیرد. رمز عبور با bcrypt رمزگذاری شده و هش آن در پایگاه داده ذخیره میشود.
ایمیل به عنوان یک شناسه منحصر به فرد در نظر گرفته میشود، در حالی که player_name برای اهداف نمایشی در بازی استفاده میشود.
این API در حال حاضر ورود به سیستم را مدیریت نمیکند، اما پیادهسازی آن میتواند به عنوان یک تمرین اضافی به شما واگذار شود.
فایل ./src/golang/profile-service/main.go برای سرویس پروفایل، دو نقطه پایانی اصلی را به شرح زیر نمایش میدهد:
func main() {
configuration, _ := config.NewConfig()
router := gin.Default()
router.SetTrustedProxies(nil)
router.Use(setSpannerConnection(configuration))
router.POST("/players", createPlayer)
router.GET("/players", getPlayerUUIDs)
router.GET("/players/:id", getPlayerByID)
router.Run(configuration.Server.URL())
}
و کد مربوط به آن نقاط پایانی به مدل بازیکن هدایت خواهد شد.
func getPlayerByID(c *gin.Context) {
var playerUUID = c.Param("id")
ctx, client := getSpannerConnection(c)
player, err := models.GetPlayerByUUID(ctx, client, playerUUID)
if err != nil {
c.IndentedJSON(http.StatusNotFound, gin.H{"message": "player not found"})
return
}
c.IndentedJSON(http.StatusOK, player)
}
func createPlayer(c *gin.Context) {
var player models.Player
if err := c.BindJSON(&player); err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
ctx, client := getSpannerConnection(c)
err := player.AddPlayer(ctx, client)
if err != nil {
c.AbortWithError(http.StatusBadRequest, err)
return
}
c.IndentedJSON(http.StatusCreated, player.PlayerUUID)
}
یکی از اولین کارهایی که سرویس انجام میدهد، تنظیم اتصال Spanner است. این کار در سطح سرویس پیادهسازی میشود تا مجموعه نشستها (session pool) برای سرویس ایجاد شود.
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
}
همانطور که میبینید، رفتار پیشفرض، اجرای سرویس روی localhost:8080 است.
با این اطلاعات، زمان اجرای سرویس فرا رسیده است.
سرویس پروفایل را اجرا کنید
سرویس را با استفاده از دستور go اجرا کنید. این دستور وابستگیها را دانلود کرده و سرویس را روی پورت ۸۰۸۰ راهاندازی میکند:
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 برای ایجاد یک بازیکن جدید، سرویس را آزمایش کردید.
مراحل بعدی
در مرحله بعد، سرویس match-making را مستقر خواهید کرد.
۵. سرویس جفتیابی را راهاندازی کنید
نمای کلی خدمات
سرویس تطبیق، یک API REST است که با زبان Go نوشته شده و از فریمورک gin بهره میبرد.

در این API، بازیها ایجاد و بسته میشوند. وقتی یک بازی ایجاد میشود، 10 بازیکن که در حال حاضر بازی نمیکنند به بازی اختصاص داده میشوند.
وقتی یک بازی بسته میشود، یک برنده به صورت تصادفی انتخاب میشود و آمار هر بازیکن برای games_played و games_won تنظیم میشود. همچنین، هر بازیکن بهروزرسانی میشود تا نشان دهد که دیگر بازی نمیکند و بنابراین برای بازی در بازیهای آینده در دسترس است.
فایل ./src/golang/matchmaking-service/main.go برای سرویس matchmaking از تنظیمات و کدهای مشابهی مانند سرویس profile پیروی میکند، بنابراین در اینجا تکرار نمیشود. این سرویس دو نقطه پایانی اصلی به شرح زیر دارد:
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"`
}
برای ایجاد یک بازی ، سرویس Matchmaking به صورت تصادفی ۱۰۰ بازیکن را که در حال حاضر بازی نمیکنند، انتخاب میکند.
جهشهای 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")
برای جلوگیری از تداخل با profile-service، این سرویس به طور پیشفرض روی localhost:8081 اجرا میشود.
با این اطلاعات، اکنون زمان اجرای سرویس جفتیابی (Matchmaking) فرا رسیده است.
سرویس جفتیابی را اجرا کنید
سرویس را با استفاده از دستور go اجرا کنید. این کار باعث میشود سرویس روی پورت ۸۰۸۲ اجرا شود. این سرویس بسیاری از وابستگیهای مشابه 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"
خلاصه
در این مرحله، شما سرویس matchmaking را برای مدیریت ایجاد بازیها و اختصاص بازیکنان به آن بازی مستقر کردید. این سرویس همچنین پایان بازی را مدیریت میکند، که یک برنده تصادفی انتخاب میکند و آمار تمام بازیکنان بازی را برای games_played و games_won بهروزرسانی میکند.
مراحل بعدی
حالا که سرویسهای شما در حال اجرا هستند، وقت آن رسیده که بازیکنان را به ثبتنام و انجام بازیها ترغیب کنید!
۶. شروع به بازی کنید
اکنون که سرویسهای پروفایل و تطبیق اجرا میشوند، میتوانید با استفاده از مولدهای ملخ ارائه شده، بار تولید کنید.
Locust یک رابط وب برای اجرای ژنراتورها ارائه میدهد، اما در این آزمایش از خط فرمان (گزینه –headless ) استفاده خواهید کرد.
ثبت نام بازیکنان
اول، شما میخواهید بازیکنان را تولید کنید.
کد پایتون برای ایجاد بازیکنان در فایل ./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
بازیکنان به بازیها ملحق میشوند
حالا که بازیکنان ثبتنام کردهاند، میخواهند بازی را شروع کنند!
کد پایتون برای ایجاد و بستن بازیها در فایل ./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)
وقتی این مولد اجرا میشود، بازیها را با نسبت ۲:۱ (open:close) باز و بسته میکند. این دستور مولد را به مدت ۱۰ ثانیه اجرا میکند (-t=10s) :
locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s
خلاصه
در این مرحله، شما ثبتنام بازیکنان برای انجام بازیها را شبیهسازی کردید و سپس شبیهسازیهایی را برای بازیکنان اجرا کردید تا با استفاده از سرویس matchmaking بازی کنند. این شبیهسازیها از چارچوب Locust Python برای ارسال درخواستها به REST API سرویسهای ما استفاده کردند.
میتوانید زمان صرف شده برای ایجاد بازیکنان و انجام بازیها و همچنین تعداد کاربران همزمان ( -u) را تغییر دهید.
مراحل بعدی
پس از شبیهسازی، میتوانید با پرسوجو از Spanner، آمارهای مختلف را بررسی کنید.
۷. بازیابی آمار بازی
حالا که ما امکان ثبتنام و بازی کردن بازیکنان را شبیهسازی کردهایم، باید آمار خود را بررسی کنید.
برای انجام این کار، از 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;
نتیجه:
شناسه بازیکن | آمار |
07e247c5-f88e-4bca-a7bc-12d2485f2f2b | {"بازیهای انجام شده":۴۹,"بازیهای برنده شده":۱} |
۰۹b۷۲۵۹۵-۴۰af-۴۴۰۶-a۰۰۰-۲fb۵۶c۵۸fe۹۲ | {"بازیهای انجام شده":56,"بازیهای برنده شده":1} |
۱۰۰۲۳۸۵b-02a0-462b-a8e7-05c9b27223aa | {"بازیهای انجام شده":66,"بازیهای برنده شده":1} |
13ec3770-7ae3-495f-9b53-6322d8e8d6c3 | {"بازیهای انجام شده":۴۴،"بازیهای برنده شده":۱} |
15513852-3f2a-494f-b437-fe7125d15f1b | {"بازیهای انجام شده":۴۹,"بازیهای برنده شده":۱} |
۱۷faec۶۴-۴f۷۷-۴۷۵c-۸df۸-۶ab۰۲۶cf۶۶۹۸ | {"بازیهای انجام شده":50,"بازیهای برنده شده":1} |
1abfcb27-037d-446d-bb7a-b5cd17b5733d | {"بازیهای انجام شده":63,"بازیهای برنده شده":1} |
2109a33e-88bd-4e74-a35c-a7914d9e3bde | {"بازیهای انجام شده":56,"بازیهای برنده شده":2} |
۲۲۲e۳۷d۹-۰۶b۰-۴۶۷۴-۸۶۵d-a۰e۵fb۸۰۱۲۱e | {"بازیهای انجام شده":60,"بازیهای برنده شده":1} |
۲۲ced۱۵c-۰da۶-۴fd۹-۸cb۲-۱ffd۲۳۳b۳c۵۶ | {"بازیهای انجام شده":50,"بازیهای برنده شده":1} |
خلاصه
در این مرحله، شما با استفاده از Cloud Console برای پرس و جو از Spanner، آمار مختلف بازیکنان و بازیها را بررسی کردید.
مراحل بعدی
بعدش، وقت تمیز کردنه!
۸. تمیز کردن (اختیاری)
برای پاکسازی، کافیست به بخش Cloud Spanner در Cloud Console بروید و نمونهی 'cloudspanner-gaming' را که در مرحلهی codelab با عنوان «راهاندازی یک نمونهی Cloud Spanner» ایجاد کردیم، حذف کنید.
۹. تبریک میگویم!
تبریک میگویم، شما با موفقیت یک بازی نمونه را روی Spanner مستقر کردید.
بعدش چی؟
در این آزمایش، شما با مباحث مختلف کار با Spanner با استفاده از درایور golang آشنا شدید. این به شما پایه بهتری برای درک مفاهیم حیاتی مانند موارد زیر میدهد:
- طراحی طرحواره
- DML در مقابل جهشها
- کار با گولنگ
برای مثال دیگری از کار با Spanner به عنوان backend برای بازی خود، حتماً به codelab مربوط به Cloud Spanner Game Trading Post نگاهی بیندازید!