Cloud Spanner شروع با توسعه بازی ها

۱. مقدمه

کلود اسپنر یک سرویس پایگاه داده رابطه‌ای، توزیع‌شده در سطح جهانی و مقیاس‌پذیر افقی است که تراکنش‌های ACID و مفاهیم SQL را بدون از دست دادن عملکرد و دسترسی‌پذیری بالا ارائه می‌دهد.

این ویژگی‌ها، اسپنر را به گزینه‌ای عالی برای معماری بازی‌هایی تبدیل می‌کند که می‌خواهند پایگاه بازیکنان جهانی داشته باشند یا نگران ثبات داده‌ها هستند.

در این آزمایش، شما دو سرویس Go ایجاد خواهید کرد که با یک پایگاه داده منطقه‌ای Spanner تعامل دارند تا بازیکنان بتوانند ثبت نام کرده و بازی را شروع کنند.

413fdd57bb0b68bc.png

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

در نهایت، منابعی را که در این آزمایشگاه ایجاد شده‌اند، پاکسازی خواهید کرد.

آنچه خواهید ساخت

به عنوان بخشی از این آزمایشگاه، شما:

  • یک نمونه Spanner ایجاد کنید
  • یک سرویس پروفایل نوشته شده در Go برای مدیریت ثبت نام بازیکن مستقر کنید
  • یک سرویس Matchmaking نوشته شده با زبان Go را برای اختصاص دادن بازیکنان به بازی‌ها، تعیین برندگان و به‌روزرسانی آمار بازی بازیکنان، مستقر کنید.

آنچه یاد خواهید گرفت

  • نحوه راه‌اندازی یک نمونه Cloud Spanner
  • نحوه ایجاد پایگاه داده و طرحواره بازی
  • نحوه‌ی پیاده‌سازی برنامه‌های Go برای کار با Cloud Spanner
  • نحوه تولید داده با استفاده از Locust
  • نحوه پرس و جو از داده‌ها در Cloud Spanner برای پاسخ به سوالات مربوط به بازی‌ها و بازیکنان.

آنچه نیاز دارید

  • یک پروژه گوگل کلود که به یک حساب صورتحساب متصل است.
  • یک مرورگر وب، مانند کروم یا فایرفاکس .

۲. تنظیمات و الزامات

ایجاد یک پروژه

اگر از قبل حساب گوگل (جیمیل یا برنامه‌های گوگل) ندارید، باید یکی ایجاد کنید . وارد کنسول پلتفرم ابری گوگل ( console.cloud.google.com ) شوید و یک پروژه جدید ایجاد کنید.

اگر از قبل پروژه‌ای دارید، روی منوی کشویی انتخاب پروژه در سمت چپ بالای کنسول کلیک کنید:

6c9406d9b014760.png

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

۹۴۹d۸۳c۸a۴ee۱۷d۹.png

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

870a3cbd6541ee86.png

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

6a92c57d3250a4b3.png

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

در مرحله بعد، اگر قبلاً این کار را نکرده‌اید، برای استفاده از منابع Google Cloud و فعال کردن Cloud Spanner API ، باید صورتحساب را در کنسول توسعه‌دهندگان فعال کنید .

15d0ef27a8fbab27.png

اجرای این آزمایشگاه کد نباید بیش از چند دلار برای شما هزینه داشته باشد، اما اگر تصمیم به استفاده از منابع بیشتر بگیرید یا اگر آنها را در حال اجرا رها کنید (به بخش «پاکسازی» در انتهای این سند مراجعه کنید)، می‌تواند بیشتر هم بشود. قیمت Google Cloud Spanner در اینجا مستند شده است.

کاربران جدید پلتفرم ابری گوگل واجد شرایط دریافت یک دوره آزمایشی رایگان ۳۰۰ دلاری هستند که این کدلب را کاملاً رایگان می‌کند.

راه‌اندازی پوسته ابری گوگل

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

این ماشین مجازی مبتنی بر دبیان، تمام ابزارهای توسعه مورد نیاز شما را در خود جای داده است. این ماشین مجازی یک دایرکتوری خانگی ۵ گیگابایتی دائمی ارائه می‌دهد و در فضای ابری گوگل اجرا می‌شود که عملکرد شبکه و احراز هویت را تا حد زیادی بهبود می‌بخشد. این بدان معناست که تنها چیزی که برای این آزمایشگاه کد نیاز دارید یک مرورگر است (بله، روی کروم‌بوک هم کار می‌کند).

  1. برای فعال کردن Cloud Shell از کنسول Cloud، کافیست روی Activate Cloud Shell کلیک کنید. gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (فقط چند لحظه طول می‌کشد تا آماده شود و به محیط متصل شود).

JjEuRXGg0AYYIY6QZ8d-66gx_Mtc-_jDE9ijmbXLJSAXFvJt-qUpNtsBsYjNpv2W6BQSr Dc1D-ARINNQ-1EkwUhz-iUK-FUCZhJ-NtjvIEx9pIkE-246DomWuCfiGHK78DgoeWkHRw

اسکرین شات 2017-06-14 ساعت 10.13.43 PM.png

پس از اتصال به Cloud Shell، باید ببینید که از قبل احراز هویت شده‌اید و پروژه از قبل روی PROJECT_ID شما تنظیم شده است.

gcloud auth list

خروجی دستور

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

خروجی دستور

[core]
project = <PROJECT_ID>

اگر به هر دلیلی پروژه تنظیم نشده باشد، کافیست دستور زیر را اجرا کنید:

gcloud config set project <PROJECT_ID>

به دنبال PROJECT_ID خود هستید؟ شناسه‌ای را که در مراحل راه‌اندازی استفاده کرده‌اید بررسی کنید یا آن را در داشبورد Cloud Console جستجو کنید:

۱۵۸fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell همچنین برخی از متغیرهای محیطی را به طور پیش‌فرض تنظیم می‌کند که ممکن است هنگام اجرای دستورات بعدی مفید باشند.

echo $GOOGLE_CLOUD_PROJECT

خروجی دستور

<PROJECT_ID>

کد را دانلود کنید

در Cloud Shell، می‌توانید کد مربوط به این آزمایشگاه را دانلود کنید. این کد بر اساس نسخه v0.1.0 است، بنابراین آن برچسب را بررسی کنید:

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

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

خروجی دستور

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

راه اندازی مولد بار ملخ

Locust یک چارچوب تست بار پایتون است که برای آزمایش نقاط پایانی 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 را جستجو کنید. ۱a۶۵۸۰bd۳d۳e۶۷۸۳.png در منوی همبرگری بالا سمت چپ ۳۱۲۹۵۸۹f۷bc۹e۵ce.png یا با فشردن کلید "/" و تایپ "Spanner" عبارت Spanner را جستجو کنید.

36e52f8df8e13b99.png

در مرحله بعد، روی کلیک کنید ۹۵۲۶۹e۷۵bc۸c۳e۴d.png و فرم را با وارد کردن نام نمونه cloudspanner-gaming برای نمونه خود، انتخاب پیکربندی (یک نمونه منطقه‌ای مانند us-central1 را انتخاب کنید) و تنظیم تعداد گره‌ها پر کنید. برای این آزمایشگاه کد، ما فقط 500 processing units نیاز داریم.

در آخر، اما نه کم‌اهمیت‌تر، روی «ایجاد» کلیک کنید و ظرف چند ثانیه یک نمونه Cloud Spanner در اختیار شما قرار می‌گیرد.

4457c324c94f93e6.png

ایجاد پایگاه داده و طرحواره

پس از اجرای نمونه، می‌توانید پایگاه داده را ایجاد کنید. Spanner امکان ایجاد چندین پایگاه داده را در یک نمونه واحد فراهم می‌کند.

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

در نمونه‌های چند منطقه‌ای، می‌توانید رهبر پیش‌فرض را نیز پیکربندی کنید. درباره پایگاه‌های داده در Spanner بیشتر بخوانید .

برای این آزمایشگاه کد، پایگاه داده را با گزینه‌های پیش‌فرض ایجاد خواهید کرد و طرحواره را در زمان ایجاد ارائه خواهید داد.

این آزمایشگاه دو جدول ایجاد خواهد کرد: بازیکنان و بازی‌ها .

77651ac12e47fe2a.png

بازیکنان می‌توانند در طول زمان در بازی‌های زیادی شرکت کنند، اما فقط در یک بازی در هر زمان. بازیکنان همچنین آمار خود را به صورت یک نوع داده JSON دارند تا آمار جالب مانند games_played و games_won را پیگیری کنند. از آنجا که ممکن است آمار دیگری بعداً اضافه شود، این ستون عملاً برای بازیکنان بدون طرح است.

بازی‌ها با استفاده از نوع داده ARRAY در Spanner، تعداد بازیکنان شرکت‌کننده را ثبت می‌کنند. ویژگی‌های برنده و پایان بازی تا زمان بسته شدن بازی، پر نمی‌شوند.

یک کلید خارجی وجود دارد تا اطمینان حاصل شود که current_game بازیکن، یک بازی معتبر است.

اکنون با کلیک بر روی «ایجاد پایگاه داده» در نمای کلی نمونه، پایگاه داده را ایجاد کنید:

a820db6c4a4d6f2d.png

و سپس جزئیات را پر کنید. گزینه‌های مهم، نام پایگاه داده و گویش آن هستند. در این مثال، ما پایگاه داده را 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);

سپس، روی دکمه ایجاد کلیک کنید و چند ثانیه صبر کنید تا پایگاه داده شما ایجاد شود.

صفحه ایجاد پایگاه داده باید به شکل زیر باشد:

d39d358dc7d32939.png

حالا، باید چند متغیر محیطی را در Cloud Shell تنظیم کنید تا بعداً در آزمایشگاه کد از آنها استفاده کنید. بنابراین instance-id را یادداشت کنید و INSTANCE_ID و DATABASE_ID را در Cloud Shell تنظیم کنید.

f6f98848d3aea9c.png

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

خلاصه

در این مرحله شما یک نمونه Spanner و پایگاه داده بازی نمونه ایجاد کردید. همچنین طرحواره‌ای را که این بازی نمونه از آن استفاده می‌کند، تعریف کرده‌اید.

بعدی

در مرحله بعد، سرویس پروفایل را مستقر خواهید کرد تا به بازیکنان اجازه دهید برای انجام بازی ثبت نام کنند!

۴. سرویس پروفایل را مستقر کنید

نمای کلی خدمات

سرویس پروفایل یک API REST است که با زبان Go نوشته شده و از فریم‌ورک gin بهره می‌برد.

4fce45ee6c858b3e.png

در این 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 بهره می‌برد.

9aecd571df0dcd7c.png

در این 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 باز کنید:

90eceac76a6bb90b.png

سپس، دستور curl زیر را اجرا کنید:

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

خروجی دستور:

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

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

بازی را ببندید

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

خروجی دستور:

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

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

خلاصه

در این مرحله، شما سرویس 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 استفاده کنید.

b5e3154c6f7cb0cf.png

بررسی بازی‌های باز در مقابل بازی‌های بسته

یک بازی بسته، بازی‌ای است که مهر زمان پایان آن پر شده است، در حالی که یک بازی باز، پس از پایان، NULL خواهد بود. این مقدار هنگام بسته شدن بازی تنظیم می‌شود.

بنابراین این کوئری به شما کمک می‌کند تا بررسی کنید که چند بازی باز و چند بازی بسته هستند:

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

نتیجه:

Type

NumGames

Open Games

0

Closed Games

175

بررسی تعداد بازیکنانی که بازی می‌کنند در مقابل بازیکنانی که بازی نمی‌کنند

اگر ستون current_game بازیکن تنظیم شده باشد، او در حال بازی است. در غیر این صورت، او در حال حاضر در حال بازی نیست.

بنابراین برای مقایسه اینکه چه تعداد بازیکن در حال حاضر بازی می‌کنند و چه تعداد بازی نمی‌کنند، از این کوئری استفاده کنید:

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

نتیجه:

Type

NumPlayers

Playing

0

Not Playing

310

تعیین نفرات برتر

وقتی یک بازی بسته می‌شود، یکی از بازیکنان به صورت تصادفی به عنوان برنده انتخاب می‌شود. آمار games_won آن بازیکن در طول بسته شدن بازی افزایش می‌یابد.

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

نتیجه:

شناسه بازیکن

آمار

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 نگاهی بیندازید!