بدء Cloud Spanner في تطوير الألعاب

1. مقدمة

Cloud Spanner هي خدمة قاعدة بيانات ارتباطية وقابلة للتطوير أفقيًا وموزعة عالميًا، وتوفّر معاملات ACID ودلالات SQL بدون التخلّي عن الأداء والتوفّر العالي.

تجعل هذه الميزات Spanner من الألعاب الرائعة التي تسعى إلى بناء قاعدة لاعبين عالميين أو تهتمّ باتساق البيانات.

في هذا التمرين المعملي، ستنشئ خدمتين من خدمات Go تتفاعل مع قاعدة بيانات Spanner إقليمية لتمكين اللاعبين من الاشتراك وبدء اللعب.

413fdd57bb0b68bc.png

بعد ذلك، ستنشئ بيانات تستفيد من إطار عمل التحميل في بايثون Locust.io لمحاكاة اشتراك اللاعبين في اللعبة وتشغيلها. بعد ذلك ستطلب من Spanner لتحديد عدد اللاعبين، وبعض الإحصاءات حول اللاعبين المباريات التي فازت بها مقارنةً بالمباريات التي تمت ممارستها.

وأخيرًا، ستقوم بتنظيف الموارد التي تم إنشاؤها في هذا التمرين المعملي.

ما الذي ستقوم ببنائه

كجزء من هذا التمرين المعملي، سوف:

  • إنشاء مثيل Spanner
  • نشر خدمة الملف الشخصي المكتوبة في "الانتقال للتعامل مع اشتراك اللاعبين"
  • انشر خدمة التعارف المكتوبة في "انتقال" لتعيين الألعاب للّاعبين وتحديد الفائزين وتحديث اللاعبين. إحصاءات اللعبة.

المُعطيات

  • كيفية إعداد مثيل Cloud Spanner
  • طريقة إنشاء مخطط وقاعدة بيانات ألعاب
  • كيفية نشر تطبيقات Go للعمل مع Cloud Spanner
  • كيفية إنشاء البيانات باستخدام Locust
  • كيفية الاستعلام عن البيانات في Cloud Spanner للإجابة عن أسئلة حول الألعاب واللاعبين.

المتطلبات

  • مشروع على Google Cloud مرتبط بحساب فوترة
  • متصفح ويب، مثل Chrome أو Firefox

2. الإعداد والمتطلبات

إنشاء مشروع

إذا لم يكن لديك حساب Google (Gmail أو Google Apps)، يجب عليك إنشاء حساب. سجِّل الدخول إلى وحدة تحكُّم Google Cloud Platform ( console.cloud.google.com) وأنشئ مشروعًا جديدًا.

إذا كان لديك مشروع بالفعل، فانقر فوق القائمة المنسدلة لاختيار المشروع في أعلى يسار وحدة التحكم:

6c9406d9b014760.png

وانقر على "مشروع جديد" في مربع الحوار الناتج لإنشاء مشروع جديد:

949d83c8a4ee17d9.png

إذا لم يكن لديك مشروع، من المفترض أن يظهر لك مربع حوار مثل هذا لإنشاء مشروعك الأول:

870a3cbd6541ee86.png

يتيح لك مربع الحوار اللاحق لإنشاء المشروع إدخال تفاصيل مشروعك الجديد:

6a92c57d3250a4b3.png

يُرجى تذكُّر رقم تعريف المشروع، وهو اسم فريد في جميع مشاريع Google Cloud (سبق أن تم استخدام الاسم أعلاه ولن يكون مناسبًا لك). ستتم الإشارة إليه لاحقًا في هذا الدرس التطبيقي حول الترميز باسم PROJECT_ID.

بعد ذلك، عليك تفعيل الفوترة في Developers Console لاستخدام موارد Google Cloud وتفعيل Cloud Spanner API إذا لم يسبق لك إجراء ذلك.

15d0ef27a8fbab27.png

لن يكلفك تنفيذ هذا الدرس التطبيقي أكثر من بضعة دولارات، ولكن قد تزيد التكاليف إذا قررت استخدام المزيد من الموارد أو إذا تركتها قيد التشغيل (يُرجى الاطّلاع على قسم "التنظيف" في نهاية هذا المستند). يمكن الاطّلاع على أسعار خدمة Google Cloud Spanner هنا.

إنّ مستخدمي Google Cloud Platform الجدد مؤهّلون للاستفادة من فترة تجريبية مجانية بقيمة 300 دولار أمريكي، ما يجعل هذا الدرس التطبيقي حول الترميز بدون أي تكلفة.

إعداد Google Cloud Shell

يمكن إدارة Google Cloud وSpanner عن بُعد من الكمبيوتر المحمول، ولكن في هذا الدرس التطبيقي حول الترميز، سنستخدم Google Cloud Shell، وهي بيئة سطر أوامر يتم تشغيلها في السحابة الإلكترونية.

هذا الجهاز الافتراضي المستند إلى نظام دبيان محمل بكل أدوات التطوير التي ستحتاج إليها. وتوفّر هذه الشبكة دليلاً رئيسيًا دائمًا بسعة 5 غيغابايت ويتم تشغيله في Google Cloud، ما يحسّن بشكل كبير من أداء الشبكة والمصادقة. وهذا يعني أنّ كل ما ستحتاجه في هذا الدرس التطبيقي حول الترميز هو متصفّح (نعم، يعمل على جهاز Chromebook).

  1. لتفعيل Cloud Shell من Cloud Console، ما عليك سوى النقر على رمز تفعيل Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (من المفترَض أن تستغرق عملية الإعداد والاتصال بالبيئة بضع دقائق فقط).

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

لقطة شاشة يوم 14-06-2017 في الساعة 10.13.43 مساءً.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:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

تضبط Cloud Shell أيضًا بعض متغيرات البيئة تلقائيًا، وهو ما قد يكون مفيدًا عند تشغيل الأوامر المستقبلية.

echo $GOOGLE_CLOUD_PROJECT

مخرجات الأمر

<PROJECT_ID>

تنزيل الرمز

يمكنك تنزيل رمز هذا التمرين المعملي في Cloud Shell. يستند ذلك إلى الإصدار v0.1.0، لذا يُرجى التحقق من هذه العلامة:

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

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

مخرجات الأمر

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

إعداد منشئ حمل الموقع الجغرافي

Locust هو إطار عمل اختبار حمولة في بايثون مفيد لاختبار نقاط نهاية REST API. في هذا الدرس التطبيقي حول الترميز، لدينا اختباران مختلفان للتحميل في "مولّدات" البحث. الدليل الذي سنسلط الضوء عليه:

  • authentication_server.py: يحتوي على مهام لإنشاء لاعبين والحصول على مشغِّل عشوائي يحاكي عمليات البحث عن نقطة واحدة.
  • match_server.py: يحتوي على مهام إنشاء ألعاب وإغلاق الألعاب. سيؤدي إنشاء الألعاب إلى تخصيص 100 لاعب عشوائي لا يلعب حاليًا. سيؤدي إغلاق الألعاب إلى تحديث إحصاءات game_played وgame_won، والسماح لهؤلاء اللاعبين بضمّهم إلى لعبة مستقبلية.

لتشغيل Locust في Cloud Shell، ستحتاج إلى Python 3.7 أو إصدار أحدث. يتوفر Cloud Shell مع Python 3.9، لذا ما مِن إجراء عليك سوى التحقّق من صحة الإصدار:

python -V

مخرجات الأمر

Python 3.9.12

الآن، يمكنك تثبيت متطلبات Locust.

pip3 install -r requirements.txt

مخرجات الأمر

Collecting locust==2.11.1
*snip*
Successfully installed ConfigArgParse-1.5.3 Flask-BasicAuth-0.2.0 Flask-Cors-3.0.10 brotli-1.0.9 gevent-21.12.0 geventhttpclient-2.0.2 greenlet-1.1.3 locust-2.11.1 msgpack-1.0.4 psutil-5.9.2 pyzmq-22.3.0 roundrobin-0.0.4 zope.event-4.5.0 zope.interface-5.4.0

عدِّل مسار PATH الآن بحيث يمكن العثور على البرنامج الثنائي locust المثبّت حديثًا:

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

مخرجات الأمر

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

ملخّص

في هذه الخطوة، تكون قد أعددت مشروعك إذا لم يسبق لك إجراء ذلك، وفعَّلت Cloud Shell، ونزّلت رمز هذا التمرين.

وأخيرًا، يمكنك إعداد Locust لإنشاء التحميل لاحقًا في التمرين المعملي.

التالي

بعد ذلك، سيتم إعداد مثيل Cloud Spanner وقاعدة البيانات.

3- إنشاء مثيل Spanner وقاعدة بيانات

إنشاء مثيل Spanner

في هذه الخطوة، يتم إعداد مثيل Spanner للدرس التطبيقي حول الترميز. ابحث عن إدخال Spanner 1a6580bd3d3e6783.pngفي قائمة همبرغر أعلى اليمين 3129589f7bc9e5ce.png أو ابحث عن Spanner عن طريق الضغط على "/" واكتب "Spanner"

36e52f8df8e13b99.png

بعد ذلك، انقر على 95269e75bc8c3e4d.png واملأ النموذج عن طريق إدخال اسم المثيل cloudspanner-gaming للمثيل واختيار إعدادات (اختَر مثيلاً محليًا مثل us-central1) وضبط عدد العُقد. سنحتاج فقط إلى 500 processing units في هذا الدرس التطبيقي حول الترميز.

أخيرًا ولي�� آخرًا، انقر على "إنشاء" وستكون لديك مثيل Cloud Spanner تحت تصرفك خلال ثوانٍ.

4457c324c94f93e6.png

إنشاء قاعدة البيانات والمخطط

بمجرد تشغيل المثيل الخاص بك، يمكنك إنشاء قاعدة البيانات. يسمح Spanner بقواعد بيانات متعددة على مثيل واحد.

قاعدة البيانات هي المكان الذي يمكنك فيه تعريف مخططك. يمكنك أيضًا التحكم في الأشخاص الذين يمكنهم الوصول إلى قاعدة البيانات، وإعداد تشفير مخصص، وتهيئة المحسِّن، وتعيين فترة الاحتفاظ.

في المواقع التي تستهدف مناطق متعددة، يمكنك أيضًا ضبط إعدادات الصدارة التلقائية. يمكنك الاطّلاع على مزيد من المعلومات حول قواعد البيانات على Spanner.

في هذا التمرين المعملي، ستُنشئ قاعدة البيانات بخيارات افتراضية، وتوفر المخطط في وقت الإنشاء.

سينشئ هذا التمرين المعملي جدولين: اللاعبين والألعاب.

77651ac12e47fe2a.png

يمكن لللاعبين المشاركة في العديد من الألعاب بمرور الوقت، ولكن في لعبة واحدة فقط في كل مرة. يمتلك اللاعبين أيضًا إحصاءات باعتبارها نوع بيانات JSON لتتبُّع الإحصاءات المهمة، مثل games_played وgames_won. ونظرًا لاحتمال إضافة إحصائيات أخرى لاحقًا، يعد هذا العمود بدون مخطط بشكل فعال للّاعبين.

تتتبّع الألعاب اللاعبين الذين شاركوا باستخدام نوع بيانات ARRAY من Spanner. ولا تتم تعبئة سمات الفائز في اللعبة والسمات المنتهية إلا بعد إغلاق اللعبة.

يتوفّر مفتاح خارجي واحد للتأكُّد من أنّ current_game صالحة للاعب.

الآن قم بإنشاء قاعدة البيانات بالنقر فوق "Create Database" (إنشاء قاعدة بيانات) في النظرة العامة على المثيل:

a820db6c4a4d6f2d.png

ثم املأ التفاصي��. الخيارات المهمة هي اسم قاعدة البيانات واللهجة. في هذا المثال، أطلقنا على قاعدة البيانات اسم نموذج لعبة واخترنا لغة SQL العادية من Google.

بالنسبة إلى المخطّط، انسخ 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 وDATABASE_ID في Cloud Shell.

f6f98848d3aea9c.png

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

ملخّص

في هذه الخطوة، أنشأت مثيل Spanner وقاعدة بيانات نموذج لعبة. لقد حدَّدت أيضًا المخطط الذي تستخدمه هذه اللعبة النموذجية.

التالي

بعد ذلك، ستنشر خدمة الملف الشخصي للسماح للّاعبين بالاشتراك في اللعبة!

4. نشر خدمة الملف الشخصي

نظرة عامة حول الخدمة

خدمة الملف الشخصي هي واجهة برمجة تطبيقات REST م��توبة بلغة Go وتستفيد من إطار عمل gin.

4fce45ee6c858b3e.png

في واجهة برمجة التطبيقات هذه، يمكن للّاعبين الاشتراك لتشغيل الألعاب. يتم إنشاء ذلك من خلال أمر POST بسيط يقبل اسم اللاعب وعنوان البريد الإلكتروني وكلمة المرور. يتم تشفير كلمة المرور باستخدام bcrypt ويتم تخزين التجزئة في قاعدة البيانات.

يتم التعامل مع البريد الإلكتروني كمعرّف فريد، بينما يتم استخدام player_name لأغراض عرض اللعبة.

لا يمكن لواجهة برمجة التطبيقات هذه حاليًا معالجة تسجيل الدخول، ولكن يمكن أن يساعدك تنفيذ هذا الإجراء لك كتمرين إضافي.

يعرض الملف ./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()
   }
}

المشغّل و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
}

ولاسترداد مشغِّل استنادًا إلى المعرّف الفريد العالمي الخاص به، يتم إصدار قراءة بسيطة. يسترد هذا الإجراء playerUUID وplayer_name والبريد الإلكتروني وstats.

func GetPlayerByUUID(ctx context.Context, client spanner.Client, uuid string) (Player, error) {
   row, err := client.Single().ReadRow(ctx, "players",
       spanner.Key{uuid}, []string{"playerUUID", "player_name", "email", "stats"})
   if err != nil {
       return Player{}, err
   }

   player := Player{}
   err = row.ToStruct(&player)

   if err != nil {
       return Player{}, err
   }
   return player, nil
}

يتم ضبط الخدمة تلقائيًا باستخدام متغيّرات البيئة. راجِع القسم ذا الصلة في الملف ./src/golang/profile-service/config/config.go.

func NewConfig() (Config, error) {
   *snip*
   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8080)

   // Bind environment variable override
   viper.BindEnv("server.host", "SERVICE_HOST")
   viper.BindEnv("server.port", "SERVICE_PORT")
   viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
   viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
   viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")

   *snip*

   return c, nil
}

ويمكنك ملاحظة أنّ السلوك التلقائي هو تشغيل الخدمة على localhost:8080.

باستخدام هذه المعلومات، حان وقت تشغيل الخدمة.

تشغيل خدمة الملف الشخصي

يمكنك تشغيل الخدمة باستخدام الأمر go. سيؤدي هذا إلى تنزيل التبعيات وإنشاء الخدمة التي تعمل على المنفذ 8080:

cd ~/spanner-gaming-sample/src/golang/profile-service
go run . &

إخراج الأمر:

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /players                  --> main.createPlayer (4 handlers)
[GIN-debug] GET    /players                  --> main.getPlayerUUIDs (4 handlers)
[GIN-debug] GET    /players/:id              --> main.getPlayerByID (4 handlers)
[GIN-debug] GET    /players/:id/stats        --> main.getPlayerStats (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8080

اختبِر الخدمة من خلال إصدار أمر curl:

curl http://localhost:8080/players \
    --include \
    --header "Content-Type: application/json" \
    --request "POST" \
    --data '{"email": "test@gmail.com","password": "s3cur3P@ss","player_name": "Test Player"}'

إخراج الأمر:

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

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

ملخّص

في هذه الخطوة، نشرت خدمة الملف الشخصي التي تسمح للّاعبين بالاشتراك لتشغيل لعبتك، واختبرت الخدمة من خلال إصدار طلب بيانات من واجهة برمجة التطبيقات POST لإنشاء لاعب جديد.

الخطوات التالية

في الخطوة التالية، ستنشر خدمة المطابقة.

5- نشر خدمة المطابقة

نظرة عامة حول الخدمة

خدمة المطابقة هي واجهة برمجة تطبيقات REST مكتوبة في Go وتستفيد من إطار عمل gin.

9aecd571df0dcd7c.png

في واجهة برمجة التطبيقات هذه، يتم إنشاء الألعاب وإغلاقها. عندما يتم إنشاء لعبة، يتم إسناد اللعبة إلى 10 لاعبين لا يلعبون حاليًا.

عندما تكون اللعبة مغلقة، يتمّ اختيار فائز عشوائيًا ولكل لاعب تم تعديل إحصاءات games_played وgames_won. بالإضافة إلى ذلك، يتم تحديث كل لاعب للإشارة إلى توقفه عن اللعب، وبالتالي سيكون بإمكانه الاستمتاع بالألعاب المستقبلية.

يتبع ملف ./src/golang/matchmaking-service/main.go لخدمة المواءمة إعدادًا ورمزًا مشابهين لخدمة 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())
}

توفّر هذه الخدمة بنية اللعبة، بالإضافة إلى بنية المشغّل وPlayerStats القصيرة:

type Game struct {
   GameUUID string           `json:"gameUUID"`
   Players  []string         `json:"players"`
   Winner   string           `json:"winner"`
   Created  time.Time        `json:"created"`
   Finished spanner.NullTime `json:"finished"`
}

type Player struct {
   PlayerUUID   string           `json:"playerUUID"`
   Stats        spanner.NullJSON `json:"stats"`
   Current_game string           `json:"current_game"`
}

type PlayerStats struct {
   Games_played int `json:"games_played"`
   Games_won    int `json:"games_won"`
}

لإنشاء لعبة، تختار خدمة التوفيق بين 100 لاعب لا يلعبون حاليًا أي لعبة.

يتم اختيار متغيّرات Spanner لإنشاء اللعبة وتخصيص اللاعبين للّاعبين، لأنّ التغييرات تكون أكثر فعالية من DML في حال حدوث تغييرات كبيرة.

// Create a new game and assign players
// Players that are not currently playing a game are eligble to be selected for the new game
// Current implementation allows for less than numPlayers to be placed in a game
func (g *Game) CreateGame(ctx context.Context, client spanner.Client) error {
   // Initialize game values
   g.GameUUID = generateUUID()

   numPlayers := 10

   // Create and assign
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       var m []*spanner.Mutation

       // get players
       query := fmt.Sprintf("SELECT playerUUID FROM (SELECT playerUUID FROM players WHERE current_game IS NULL LIMIT 10000) TABLESAMPLE RESERVOIR (%d ROWS)", numPlayers)
       stmt := spanner.Statement{SQL: query}
       iter := txn.Query(ctx, stmt)

       playerRows, err := readRows(iter)
       if err != nil {
           return err
       }

       var playerUUIDs []string

       for _, row := range playerRows {
           var pUUID string
           if err := row.Columns(&pUUID); err != nil {
               return err
           }

           playerUUIDs = append(playerUUIDs, pUUID)
       }

       // Create the game
       gCols := []string{"gameUUID", "players", "created"}
       m = append(m, spanner.Insert("games", gCols, []interface{}{g.GameUUID, playerUUIDs, time.Now()}))

       // Update players to lock into this game
       for _, p := range playerUUIDs {
           pCols := []string{"playerUUID", "current_game"}
           m = append(m, spanner.Update("players", pCols, []interface{}{p, g.GameUUID}))
       }

       txn.BufferWrite(m)

       return nil
   })

   if err != nil {
       return err
   }

   return nil
}

يتم الاختيار العشوائي للّاعبين من خلال SQL باستخدام إمكانية TABLESPACE RESERVOIR في GoogleSQL.

إنّ إغلاق اللعبة أكثر تعقيدًا إلى حدٍّ ما. تتضمن هذه العملية اختيار فائز عشوائيًا من بين لاعبي اللعبة، وتحديد وقت انتهاء اللعبة، وتحديث كل لاعب إحصاءات games_played وgames_won

بسبب هذه التعقيدات ومقدار التغييرات، يتم اختيار التغييرات مرة أخرى لإغلاق اللعبة.

func determineWinner(playerUUIDs []string) string {
   if len(playerUUIDs) == 0 {
       return ""
   }

   var winnerUUID string

   rand.Seed(time.Now().UnixNano())
   offset := rand.Intn(len(playerUUIDs))
   winnerUUID = playerUUIDs[offset]
   return winnerUUID
}

// Given a list of players and a winner's UUID, update players of a game
// Updating players involves closing out the game (current_game = NULL) and
// updating their game stats. Specifically, we are incrementing games_played.
// If the player is the determined winner, then their games_won stat is incremented.
func (g Game) updateGamePlayers(ctx context.Context, players []Player, txn *spanner.ReadWriteTransaction) error {
   for _, p := range players {
       // Modify stats
       var pStats PlayerStats
       json.Unmarshal([]byte(p.Stats.String()), &pStats)

       pStats.Games_played = pStats.Games_played + 1

       if p.PlayerUUID == g.Winner {
           pStats.Games_won = pStats.Games_won + 1
       }
       updatedStats, _ := json.Marshal(pStats)
       p.Stats.UnmarshalJSON(updatedStats)

       // Update player
       // If player's current game isn't the same as this game, that's an error
       if p.Current_game != g.GameUUID {
           errorMsg := fmt.Sprintf("Player '%s' doesn't belong to game '%s'.", p.PlayerUUID, g.GameUUID)
           return errors.New(errorMsg)
       }

       cols := []string{"playerUUID", "current_game", "stats"}
       newGame := spanner.NullString{
           StringVal: "",
           Valid:     false,
       }

       txn.BufferWrite([]*spanner.Mutation{
           spanner.Update("players", cols, []interface{}{p.PlayerUUID, newGame, p.Stats}),
       })
   }

   return nil
}

// Closing game. When provided a Game, choose a random winner and close out the game.
// A game is closed by setting the winner and finished time.
// Additionally all players' game stats are updated, and the current_game is set to null to allow
// them to be chosen for a new game.
func (g *Game) CloseGame(ctx context.Context, client spanner.Client) error {
   // Close game
   _, err := client.ReadWriteTransaction(ctx,
       func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
           // Get game players
           playerUUIDs, players, err := g.getGamePlayers(ctx, txn)

           if err != nil {
               return err
           }

           // Might be an issue if there are no players!
           if len(playerUUIDs) == 0 {
               errorMsg := fmt.Sprintf("No players found for game '%s'", g.GameUUID)
               return errors.New(errorMsg)
           }

           // Get random winner
           g.Winner = determineWinner(playerUUIDs)

           // Validate game finished time is null
           row, err := txn.ReadRow(ctx, "games", spanner.Key{g.GameUUID}, []string{"finished"})
           if err != nil {
               return err
           }

           if err := row.Column(0, &g.Finished); err != nil {
               return err
           }

           // If time is not null, then the game is already marked as finished. 
           // That's an error.
           if !g.Finished.IsNull() {
               errorMsg := fmt.Sprintf("Game '%s' is already finished.", g.GameUUID)
               return errors.New(errorMsg)
           }

           cols := []string{"gameUUID", "finished", "winner"}
           txn.BufferWrite([]*spanner.Mutation{
               spanner.Update("games", cols, []interface{}{g.GameUUID, time.Now(), g.Winner}),
           })

           // Update each player to increment stats.games_played 
           // (and stats.games_won if winner), and set current_game 
           // to null so they can be chosen for a new game
           playerErr := g.updateGamePlayers(ctx, players, txn)
           if playerErr != nil {
               return playerErr
           }

           return nil
       })

   if err != nil {
       return err
   }

   return nil
}

تتم معالجة الإعدادات مرة أخرى باستخدام متغيّرات البيئة كما هو م��ض��ّح في ./src/golang/matchmaking-service/config/config.go للخدمة.

   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8081)

   // Bind environment variable override
   viper.BindEnv("server.host", "SERVICE_HOST")
   viper.BindEnv("server.port", "SERVICE_PORT")
   viper.BindEnv("spanner.project_id", "SPANNER_PROJECT_ID")
   viper.BindEnv("spanner.instance_id", "SPANNER_INSTANCE_ID")
   viper.BindEnv("spanner.database_id", "SPANNER_DATABASE_ID")

لتجنُّب التعارضات مع خدمة الملف الشخصي، يتم تشغيل هذه الخدمة على localhost:8081 تلقائيًا.

وبفضل هذه المعلومات، حان الوقت الآن لتشغيل خدمة المواءمة.

تشغيل خدمة المطابقة

يمكنك تشغيل الخدمة باستخدام الأمر go. سيؤدي هذا إلى إنشاء الخدمة قيد التشغيل على المنفذ 8082. تحتوي هذه الخدمة على العديد من التبعيات نفسها مثل خدمة الملف الشخصي، لذلك لن يتم تنزيل التبعيات الجديدة.

cd ~/spanner-gaming-sample/src/golang/matchmaking-service
go run . &

إخراج الأمر:

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /games/create             --> main.createGame (4 handlers)
[GIN-debug] PUT    /games/close              --> main.closeGame (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8081

إنشاء لعبة

اختبِر الخدمة لإنشاء لعبة. أولاً، افتح وحدة طرفية جديدة في Cloud Shell:

90eceac76a6bb90b.png

بعد ذلك، يمكنك إصدار أمر curl التالي:

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

إخراج الأمر:

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

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

إغلاق اللعبة

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

إخراج الأمر:

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

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

ملخّص

في هذه الخطوة، نشرت خدمة التوفيق بين اللاعبين لإنشاء الألعاب ومنح اللاعبين لهذه اللعبة. تعالج هذه الخدمة أيضًا إغلاق اللعبة، حيث تختار فائزًا عشوائيًا وتعدِّل جميع اللاعبين في اللعبة. إحصاءات games_played وgames_won

الخطوات التالية

الآن وبعد تشغيل خدماتك، حان وقت تشجيع اللاعبين على الاشتراك ولعب الألعاب!

6- بدء اللعب

والآن بعد أن تم تشغيل خدمات الملف الشخصي والمواءمة، يمكنك توليد حِمل باستخدام أدوات إنشاء الجلاود المتوفرة.

يوفر Locust واجهة ويب لتشغيل أدوات إنشاء المنشئين، ولكنك ستستخدم في هذا التمرين المعملي سطر الأوامر (خيار -بلا واجهة مستخدم رسومية).

اشتراك اللاعبين

أولاً، عليك إنشاء لاعبين.

يظهر رمز بايثون لإنشاء مشغّلات في الملف ./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 ثانية (./generators/authentication_server.py بتزامن بين سلسلتَي محادثات في الوقت نفسه (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)

عند تشغيل هذا المنشئ، سيفتح ويغلق الألعاب بنسبة 2:1 (فتح:إغلاق). سيؤدي هذا الأمر إلى تشغيل أداة الإنشاء لمدة 10 ثوانٍ (-t=10s):

locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s

ملخّص

في هذه الخطوة، حاكيت اللاعبين الذين يشتركون لتشغيل الألعاب، ثم أجريت عمليات محاكاة للاعبين لتشغيل الألعاب باستخدام خدمة التعارف. استفادت عمليات المحاكاة هذه من إطار عمل Locust Python لإصدار طلبات إلى خدماتنا واجهة برمجة تطبيقات REST.

يمكنك تعديل الوقت المُستغرق في إنشاء اللاعبين وممارسة الألعاب، بالإضافة إلى عدد المستخدمين المتزامنين (-u).

الخطوات التالية

بعد المحاكاة، ستحتاج إلى التحقق من إحصاءات مختلفة من خلال الاستعلام عن Spanner.

7. استرداد إحصاءات اللعبة

الآن بعد أن استطعنا محاكاة اللاعبين الذين يمكنهم الاشتراك ولعب الألعاب، عليك التحقق من إحصاءاتك.

ولإجراء ذلك، استخدِم Cloud Console لإصدار طلبات البحث إلى Spanner.

b5e3154c6f7cb0cf.png

الاطّلاع على المباريات المفتوحة مقارنةً بالمباريات المغلقة

اللعبة المغلقة هي التي تمت تعبئة الطابع الزمني النهائي لها، في حين تكون اللعبة المفتوحة منتهية وتكون فارغة (NULL). يتم ضب�� هذه الق��مة ��ند إغلاق اللعبة.

إذًا، سيتيح لك هذا الاستعلام التحقق من عدد الألعاب المفتوحة وعدد الألعاب المغلقة:

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

النتيجة:

Type

NumGames

Open Games

0

Closed Games

175

التحقق من عدد اللاعبين الذين يلعبون مقابل عدم اللعب

يلعب أحد اللاعبين لعبة في حال ضبط عمود current_game. وبخلاف ذلك، لن يلعب المستخدمون حاليًا أي لعبة.

إذًا لمقارنة عدد اللاعبين الذين يلعبون ولا يلعبون حاليًا، استخدم هذا الاستعلام:

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

النتيجة:

Type

NumPlayers

Playing

0

Not Playing

310

تحديد أفضل الألعاب الفائزة

وعند إغلاق اللعبة، يتم اختيار أحد اللاعبين بشكل عشوائي ليكون الفائز. تتم زيادة إحصاءات games_won لهذا اللاعب خلال إغلاق اللعبة.

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

النتيجة:

playerUUID

الإحصائيات

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

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

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

{&quot;games_played&quot;:56,&quot;games_won&quot;:1}

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

{&quot;games_played&quot;:66,&quot;games_won&quot;:1}

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

{&quot;games_played&quot;:44,&quot;games_won&quot;:1}

15513852-3f2a-494f-b437-fe7125d15f1b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

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

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

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

{&quot;games_played&quot;:63,&quot;games_won&quot;:1}

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

{&quot;games_played&quot;:56,&quot;games_won&quot;:2}

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

{&quot;games_played&quot;:60,&quot;games_won&quot;:1}

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

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

ملخّص

في هذه الخطوة، تمت مراجعة إحصاءات مختلفة للّاعبين والألعاب باستخدام Cloud Console للاستعلام عن Spanner.

الخطوات التالية

بعد ذلك، حان وقت التنظيف!

8. التنظيف (اختياري)

لتنظيف البيانات، انتقِل إلى قسم Cloud Spanner في Cloud Console واحذف مثيل "cloudspanner-gaming" الذي أنشأناه في خطوة الدرس التطبيقي حول الترميز باسم "إعداد مثيل Cloud Spanner".

9. تهانينا!

تهانينا، لقد نجحت في نشر نموذج لعبة على Spanner.

ما هي الخطوات التالية؟

لقد تعرفت في هذا التمرين المعملي على موضوعات مختلفة للعمل مع Spanner باستخدام برنامج تشغيل golang. ويجب أن يوفر لك أساسًا أفضل لفهم المفاهيم الهامة مثل:

  • تصميم المخطط
  • DML في مقابل التغيُّرات
  • العمل مع Golang

ننصحك بالاطّلاع على الدرس التطبيقي حول الترميز Cloud Spanner Game Trading Post للتعرّف على مثال آخر حول استخدام Spanner كخلفية للعبتك.