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

1. مقدمة

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

تجعل هذه الميزات من Spanner خيارًا مناسبًا لبنية الألعاب التي تريد إتاحة قاعدة لاعبين عالمية أو التي تهتم باتساق البيانات.

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

413fdd57bb0b68bc.png

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

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

ما ستنشئه

في هذا الدرس التطبيقي، ستنفّذ ما يلي:

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

أهداف الدورة التعليمية

  • كيفية إعداد مثيل 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، وهي بيئة سطر أوامر تعمل في السحابة الإلكترونية.

يتم تحميل هذا الجهاز الافتراضي المستند إلى Debian بجميع أدوات التطوير التي تحتاج إليها. توفّر هذه الخدمة دليلًا رئيسيًا دائمًا بسعة 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

Screen Shot 2017-06-14 at 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:

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

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

  • authentication_server.py: يحتوي على مهام لإنشاء لاعبين، والحصول على لاعب عشوائي لمحاكاة عمليات البحث بنقطة واحدة.
  • match_server.py: يحتوي على مهام لإنشاء الألعاب وإغلاقها. سيؤدي إنشاء ألعاب إلى تعيين 100 لاعب عشوائي لا يلعبون حاليًا. سيؤدي إغلاق الألعاب إلى تعديل إحصاءات games_played وgames_won، كما سيسمح بتعيين هؤلاء اللاعبين في مباراة مستقبلية.

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

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

ثم املأ التفاصيل. الخيارات المهمة هي اسم قاعدة البيانات واللغة. في هذا المثال، سمّينا قاعدة البيانات sample-game واخترنا إصدار Google العادي من لغة الاستعلامات البنيوية (SQL).

بالنسبة إلى المخطط، انسخ رمز تعريف البيانات هذا والصقه في المربّع:

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 وقاعدة بيانات sample-game. لقد حدّدت أيضًا المخطط الذي تستخدمه هذه اللعبة النموذجية.

التالي

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

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

نظرة عامة على الخدمة

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

4fce45ee6c858b3e.png

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

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

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

يعرض ملف ./src/golang/profile-service/main.go الخاص بخدمة الملف الشخصي نقطتَي نهاية أساسيتَين على النحو التالي:

func main() {
   configuration, _ := config.NewConfig()

   router := gin.Default()
   router.SetTrustedProxies(nil)

   router.Use(setSpannerConnection(configuration))

   router.POST("/players", createPlayer)
   router.GET("/players", getPlayerUUIDs)
   router.GET("/players/:id", getPlayerByID)

   router.Run(configuration.Server.URL())
}

وسيتم توجيه الرمز الخاص بنقاط النهاية هذه إلى نموذج المشغّل.

func getPlayerByID(c *gin.Context) {
   var playerUUID = c.Param("id")

   ctx, client := getSpannerConnection(c)

   player, err := models.GetPlayerByUUID(ctx, client, playerUUID)
   if err != nil {
       c.IndentedJSON(http.StatusNotFound, gin.H{"message": "player not found"})
       return
   }

   c.IndentedJSON(http.StatusOK, player)
}

func createPlayer(c *gin.Context) {
   var player models.Player

   if err := c.BindJSON(&player); err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   ctx, client := getSpannerConnection(c)
   err := player.AddPlayer(ctx, client)
   if err != nil {
       c.AbortWithError(http.StatusBadRequest, err)
       return
   }

   c.IndentedJSON(http.StatusCreated, player.PlayerUUID)
}

من أولى الإجراءات التي تتخذها الخدمة هي ضبط اتصال Spanner. يتم تنفيذ ذلك على مستوى الخدمة لإنشاء مجموعة الجلسات الخاصة بالخدمة.

func setSpannerConnection() gin.HandlerFunc {
   ctx := context.Background()
   client, err := spanner.NewClient(ctx, configuration.Spanner.URL())

   if err != nil {
       log.Fatal(err)
   }

   return func(c *gin.Context) {
       c.Set("spanner_client", *client)
       c.Set("spanner_context", ctx)
       c.Next()
   }
}

Player وPlayerStats هما بنيتان محددتان على النحو التالي:

type Player struct {
   PlayerUUID      string `json:"playerUUID" validate:"omitempty,uuid4"`
   Player_name     string `json:"player_name" validate:"required_with=Password Email"`
   Email           string `json:"email" validate:"required_with=Player_name Password,email"`
   // not stored in DB
   Password        string `json:"password" validate:"required_with=Player_name Email"` 
   // stored in DB
   Password_hash   []byte `json:"password_hash"`                                       
   created         time.Time
   updated         time.Time
   Stats           spanner.NullJSON `json:"stats"`
   Account_balance big.Rat          `json:"account_balance"`
   last_login      time.Time
   is_logged_in    bool
   valid_email     bool
   Current_game    string `json:"current_game" validate:"omitempty,uuid4"`
}

type PlayerStats struct {
   Games_played spanner.NullInt64 `json:"games_played"`
   Games_won    spanner.NullInt64 `json:"games_won"`
}

تستفيد الدالة التي تضيف اللاعب من عملية إدراج DML داخل معاملة ReadWrite، لأنّ إضافة اللاعبين هي عبارة واحدة وليست عمليات إدراج مجمّعة. تبدو الدالة على النحو التالي:

func (p *Player) AddPlayer(ctx context.Context, client spanner.Client) error {
   // Validate based on struct validation rules
   err := p.Validate()
   if err != nil {
       return err
   }

   // take supplied password+salt, hash. Store in user_password
   passHash, err := hashPassword(p.Password)

   if err != nil {
       return errors.New("Unable to hash password")
   }

   p.Password_hash = passHash

   // Generate UUIDv4
   p.PlayerUUID = generateUUID()

   // Initialize player stats
   emptyStats := spanner.NullJSON{Value: PlayerStats{
       Games_played: spanner.NullInt64{Int64: 0, Valid: true},
       Games_won:    spanner.NullInt64{Int64: 0, Valid: true},
   }, Valid: true}

   // insert into spanner
   _, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       stmt := spanner.Statement{
           SQL: `INSERT players (playerUUID, player_name, email, password_hash, created, stats) VALUES
                   (@playerUUID, @playerName, @email, @passwordHash, CURRENT_TIMESTAMP(), @pStats)
           `,
           Params: map[string]interface{}{
               "playerUUID":   p.PlayerUUID,
               "playerName":   p.Player_name,
               "email":        p.Email,
               "passwordHash": p.Password_hash,
               "pStats":       emptyStats,
           },
       }

       _, err := txn.Update(ctx, stmt)
       return err
   })
   if err != nil {
       return err
   }
   // return empty error on success
   return nil
}

لاسترداد لاعب استنادًا إلى معرّفه الفريد العالمي (UUID)، يتم إصدار عملية قراءة بسيطة. يؤدي ذلك إلى استرداد playerUUID وplayer_name وemail والإحصاءات الخاصة باللاعب.

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

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

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

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

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

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

   *snip*

   return c, nil
}

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

بعد توفّر هذه المعلومات، يمكنك تشغيل الخدمة.

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

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

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

ناتج الأمر:

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

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

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

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

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

ناتج الأمر:

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

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

ملخّص

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

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

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

5- تفعيل خدمة المطابقة

نظرة عامة على الخدمة

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

9aecd571df0dcd7c.png

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

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

يتبع الملف ‎./src/golang/matchmaking-service/main.go الخاص بخدمة مواءمة اللاعبين إعدادًا ورمزًا مشابهَين لخدمة الملف الشخصي، لذا لن نكرّرهما هنا. تعرض هذه الخدمة نقطتَي نهاية أساسيتَين على النحو التالي:

func main() {
   router := gin.Default()
   router.SetTrustedProxies(nil)

   router.Use(setSpannerConnection())

   router.POST("/games/create", createGame)
   router.PUT("/games/close", closeGame)

   router.Run(configuration.Server.URL())
}

توفّر هذه الخدمة بنية Game، بالإضافة إلى بنيتَي Player وPlayerStats المخفّفتَين:

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

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

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

لإنشاء لعبة، تستخدم خدمة البحث عن لاعبين عينة عشوائية تضم 100 لاعب لا يلعبون حاليًا أي لعبة.

يتم اختيار عمليات التغيير في 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 المتوفّرة.

توفّر Locust واجهة ويب لتشغيل المولدات، ولكن في هذا المختبر، ستستخدم سطر الأوامر (الخيار ‎–headless).

تسجيل اللاعبين

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

يبدو رمز Python لإنشاء اللاعبين في الملف ./generators/authentication_server.py على النحو التالي:

class PlayerLoad(HttpUser):
   def on_start(self):
       global pUUIDs
       pUUIDs = []

   def generatePlayerName(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

   def generatePassword(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32))

   def generateEmail(self):
       return ''.join(random.choices(string.ascii_lowercase + string.digits, k=32) + ['@'] +
           random.choices(['gmail', 'yahoo', 'microsoft']) + ['.com'])

   @task
   def createPlayer(self):
       headers = {"Content-Type": "application/json"}
       data = {"player_name": self.generatePlayerName(), "email": self.generateEmail(), "password": self.generatePassword()}

       with self.client.post("/players", data=json.dumps(data), headers=headers, catch_response=True) as response:
           try:
               pUUIDs.append(response.json())
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'gameUUID'")

يتم إنشاء أسماء اللاعبين وعناوين البريد الإلكتروني وكلمات المرور بشكل عشوائي.

سيتم استرداد اللاعبين الذين تم الاشتراك في البرنامج بنجاح من خلال مهمة ثانية لإنشاء حمل قراءة.

   @task(5)
   def getPlayer(self):
       # No player UUIDs are in memory, reschedule task to run again later.
       if len(pUUIDs) == 0:
           raise RescheduleTask()

       # Get first player in our list, removing it to avoid contention from concurrent requests
       pUUID = pUUIDs[0]
       del pUUIDs[0]

       headers = {"Content-Type": "application/json"}

       self.client.get(f"/players/{pUUID}", headers=headers, name="/players/[playerUUID]")

يستدعي الأمر التالي الملف ./generators/authentication_server.py الذي سينشئ لاعبين جددًا لمدة 30 ثانية (t=30s) بتزامن مؤشّرين ترابط في المرة الواحدة (u=2):

cd ~/spanner-gaming-sample
locust -H http://127.0.0.1:8080 -f ./generators/authentication_server.py --headless -u=2 -r=2 -t=30s

انضمام اللاعبين إلى الألعاب

بعد أن سجّل اللاعبون اشتراكهم، يريدون بدء لعب الألعاب.

يبدو رمز Python لإنشاء الألعاب وإغلاقها في الملف ./generators/match_server.py على النحو التالي:

from locust import HttpUser, task
from locust.exception import RescheduleTask

import json

class GameMatch(HttpUser):
   def on_start(self):
       global openGames
       openGames = []

   @task(2)
   def createGame(self):
       headers = {"Content-Type": "application/json"}

       # Create the game, then store the response in memory of list of open games.
       with self.client.post("/games/create", headers=headers, catch_response=True) as response:
           try:
               openGames.append({"gameUUID": response.json()})
           except json.JSONDecodeError:
               response.failure("Response could not be decoded as JSON")
           except KeyError:
               response.failure("Response did not contain expected key 'gameUUID'")


   @task
   def closeGame(self):
       # No open games are in memory, reschedule task to run again later.
       if len(openGames) == 0:
           raise RescheduleTask()

       headers = {"Content-Type": "application/json"}

       # Close the first open game in our list, removing it to avoid 
       # contention from concurrent requests
       game = openGames[0]
       del openGames[0]

       data = {"gameUUID": game["gameUUID"]}
       self.client.put("/games/close", data=json.dumps(data), headers=headers)

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

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

ملخّص

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

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

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

بعد المحاكاة، عليك التحقّق من الإحصاءات المختلفة من خلال طلب البحث في Spanner.

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

بعد أن أتحنا للاعبين المحاكَين إمكانية الاشتراك وتشغيل الألعاب، عليك الاطّلاع على إحصاءاتك.

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

b5e3154c6f7cb0cf.png

التحقّق من الألعاب المفتوحة والمغلقة

اللعبة المغلقة هي تلك التي تم ملء الطابع الزمني finished فيها، بينما تكون قيمة finished في اللعبة المفتوحة هي 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

{"games_played":49,"games_won":1}

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

{"games_played":56,"games_won":1}

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

{"games_played":66,"games_won":1}

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

{"games_played":44,"games_won":1}

15513852-3f2a-494f-b437-fe7125d15f1b

{"games_played":49,"games_won":1}

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

{"games_played":50,"games_won":1}

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

{"games_played":63,"games_won":1}

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

{"games_played":56,"games_won":2}

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

{"games_played":60,"games_won":1}

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

{"games_played":50,"games_won":1}

ملخّص

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

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

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

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

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

9- تهانينا!

تهانينا، لقد نشرت عيّنة لعبة بنجاح على Spanner

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

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

  • تصميم المخطط
  • لغة معالجة البيانات (DML) مقابل عمليات التغيير
  • العمل باستخدام Golang

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