1. مقدمة
Cloud Spanner هي خدمة قاعدة بيانات ارتباطية وقابلة للتطوير أفقيًا وموزعة عالميًا، وتوفّر معاملات ACID ودلالات SQL بدون التخلّي عن الأداء والتوفّر العالي.
تجعل هذه الميزات Spanner من الألعاب الرائعة التي تسعى إلى بناء قاعدة لاعبين عالميين أو تهتمّ باتساق البيانات.
في هذا التمرين المعملي، ستنشئ خدمتين من خدمات Go تتفاعل مع قاعدة بيانات Spanner إقليمية لتمكين اللاعبين من الاشتراك وبدء اللعب.
بعد ذلك، ستنشئ بيانات تستفيد من إطار عمل التحميل في بايثون Locust.io لمحاكاة اشتراك اللاعبين في اللعبة وتشغيلها. بعد ذلك ستطلب من Spanner لتحديد عدد اللاعبين، وبعض الإحصاءات حول اللاعبين المباريات التي فازت بها مقارنةً بالمباريات التي تمت ممارستها.
وأخيرًا، ستقوم بتنظيف الموارد التي تم إنشاؤها في هذا التمرين المعملي.
ما الذي ستقوم ببنائه
كجزء من هذا التمرين المعملي، سوف:
- إنشاء مثيل Spanner
- نشر خدمة الملف الشخصي المكتوبة في "الانتقال للتعامل مع اشتراك اللاعبين"
- انشر خدمة التعارف المكتوبة في "انتقال" لتعيين الألعاب للّاعبين وتحديد الفائزين وتحديث اللاعبين. إحصاءات اللعبة.
المُعطيات
- كيفية إعداد مثيل Cloud Spanner
- طريقة إنشاء مخطط وقاعدة بيانات ألعاب
- كيفية نشر تطبيقات Go للعمل مع Cloud Spanner
- كيفية إنشاء البيانات باستخدام Locust
- كيفية الاستعلام عن البيانات في Cloud Spanner للإجابة عن أسئلة حول الألعاب واللاعبين.
المتطلبات
2. الإعداد والمتطلبات
إنشاء مشروع
إذا لم يكن لديك حساب Google (Gmail أو Google Apps)، يجب عليك إنشاء حساب. سجِّل الدخول إلى وحدة تحكُّم Google Cloud Platform ( console.cloud.google.com) وأنشئ مشروعًا جديدًا.
إذا كان لديك مشروع بالفعل، فانقر فوق القائمة المنسدلة لاختيار المشروع في أعلى يسار وحدة التحكم:
وانقر على "مشروع جديد" في مربع الحوار الناتج لإنشاء مشروع جديد:
إذا لم يكن لديك مشروع، من المفترض أن يظهر لك مربع حوار مثل هذا لإنشاء مشروعك الأول:
يتيح لك مربع الحوار اللاحق لإنشاء المشروع إدخال تفاصيل مشروعك الجديد:
يُرجى تذكُّر رقم تعريف المشروع، وهو اسم فريد في جميع مشاريع Google Cloud (سبق أن تم استخدام الاسم أعلاه ولن يكون مناسبًا لك). ستتم الإشارة إليه لاحقًا في هذا الدرس التطبيقي حول الترميز باسم PROJECT_ID.
بعد ذلك، عليك تفعيل الفوترة في Developers Console لاستخدام موارد Google Cloud وتفعيل Cloud Spanner API إذا لم يسبق لك إجراء ذلك.
لن يكلفك تنفيذ هذا الدرس التطبيقي أكثر من بضعة دولارات، ولكن قد تزيد التكاليف إذا قررت استخدام المزيد من الموارد أو إذا تركتها قيد التشغيل (يُرجى الاطّلاع على قسم "التنظيف" في نهاية هذا المستند). يمكن الاطّلاع على أسعار خدمة Google Cloud Spanner هنا.
إنّ مستخدمي Google Cloud Platform الجدد مؤهّلون للاستفادة من فترة تجريبية مجانية بقيمة 300 دولار أمريكي، ما يجعل هذا الدرس التطبيقي حول الترميز بدون أي تكلفة.
إعداد Google Cloud Shell
يمكن إدارة Google Cloud وSpanner عن بُعد من الكمبيوتر المحمول، ولكن في هذا الدرس التطبيقي حول الترميز، سنستخدم Google Cloud Shell، وهي بيئة سطر أوامر يتم تشغيلها في السحابة الإلكترونية.
هذا الجهاز الافتراضي المستند إلى نظام دبيان محمل بكل أدوات التطوير التي ستحتاج إليها. وتوفّر هذه الشبكة دليلاً رئيسيًا دائمًا بسعة 5 غيغابايت ويتم تشغيله في Google Cloud، ما يحسّن بشكل كبير من أداء الشبكة والمصادقة. وهذا يعني أنّ كل ما ستحتاجه في هذا الدرس التطبيقي حول الترميز هو متصفّح (نعم، يعمل على جهاز Chromebook).
- لتفعيل Cloud Shell من Cloud Console، ما عليك سوى النقر على رمز تفعيل Cloud Shell (من المفترَض أن تستغرق عملية الإعداد والاتصال بالبيئة بضع دقائق فقط).
بعد الربط بـ Cloud Shell، من المفترض أن ترى أنه قد تمت مصادقتك وأنه تم تعيين المشروع على PROJECT_ID.
gcloud auth list
مخرجات الأمر
Credentialed accounts:
- <myaccount>@<mydomain>.com (active)
gcloud config list project
مخرجات الأمر
[core]
project = <PROJECT_ID>
إذا لم يتم ضبط المشروع لسبب ما، ما عليك سوى إصدار الأمر التالي:
gcloud config set project <PROJECT_ID>
هل تبحث عن PROJECT_ID؟ تحقَّق من المعرّف الذي استخدمته في خطوات الإعداد أو ابحث عنه في لوحة بيانات Cloud Console:
تضبط Cloud Shell أيضًا بعض متغيرات البيئة تلقائيًا، وهو ما قد يكون مفيدًا عند تشغيل الأوامر المستقبلية.
echo $GOOGLE_CLOUD_PROJECT
مخرجات الأمر
<PROJECT_ID>
تنزيل الرمز
يمكنك تنزيل رمز هذا التمرين المعملي في Cloud Shell. يستند ذلك إلى الإصدار v0.1.0، لذا يُرجى التحقق من هذه العلامة:
git clone https://github.com/cloudspannerecosystem/spanner-gaming-sample.git
cd spanner-gaming-sample/
# Check out v0.1.0 release
git checkout tags/v0.1.0 -b v0.1.0-branch
مخرجات الأمر
Cloning into 'spanner-gaming-sample'...
*snip*
Switched to a new branch 'v0.1.0-branch'
إعداد منشئ حمل الموقع الجغرافي
Locust هو إطار عمل اختبار حمولة في بايثون مفيد لاختبار نقاط نهاية 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 في قائمة همبرغر أعلى اليمين أو ابحث عن Spanner عن طريق الضغط على "/" واكتب "Spanner"
بعد ذلك، انقر على واملأ النموذج عن طريق إدخال اسم المثيل cloudspanner-gaming
للمثيل واختيار إعدادات (اختَر مثيلاً محليًا مثل us-central1
) وضبط عدد العُقد. سنحتاج فقط إلى 500 processing units
في هذا الدرس التطبيقي حول الترميز.
أخيرًا وليس آخرًا، انقر على "إنشاء" وستكون لديك مثيل Cloud Spanner تحت تصرفك خلال ثوانٍ.
إنشاء قاعدة البيانات والمخطط
بمجرد تشغيل المثيل الخاص بك، يمكنك إنشاء قاعدة البيانات. يسمح Spanner بقواعد بيانات متعددة على مثيل واحد.
قاعدة البيانات هي المكان الذي يمكنك فيه تعريف مخططك. يمكنك أيضًا التحكم في الأشخاص الذين يمكنهم الوصول إلى قاعدة البيانات، وإعداد تشفير مخصص، وتهيئة المحسِّن، وتعيين فترة الاحتفاظ.
في المواقع التي تستهدف مناطق متعددة، يمكنك أيضًا ضبط إعدادات الصدارة التلقائية. يمكنك الاطّلاع على مزيد من المعلومات حول قواعد البيانات على Spanner.
في هذا التمرين المعملي، ستُنشئ قاعدة البيانات بخيارات افتراضية، وتوفر المخطط في وقت الإنشاء.
سينشئ هذا التمرين المعملي جدولين: اللاعبين والألعاب.
يمكن لللاعبين المشاركة في العديد من الألعاب بمرور الوقت، ولكن في لعبة واحدة فقط في كل مرة. يمتلك اللاعبين أيضًا إحصاءات باعتبارها نوع بيانات JSON لتتبُّع الإحصاءات المهمة، مثل games_played وgames_won. ونظرًا لاحتمال إضافة إحصائيات أخرى لاحقًا، يعد هذا العمود بدون مخطط بشكل فعال للّاعبين.
تتتبّع الألعاب اللاعبين الذين شاركوا باستخدام نوع بيانات ARRAY من Spanner. ولا تتم تعبئة سمات الفائز في اللعبة والسمات المنتهية إلا بعد إغلاق اللعبة.
يتوفّر مفتاح خارجي واحد للتأكُّد من أنّ current_game صالحة للاعب.
الآن قم بإنشاء قاعدة البيانات بالنقر فوق "Create Database" (إنشاء قاعدة بيانات) في النظرة العامة على المثيل:
ثم املأ التفاصيل. الخيارات المهمة هي اسم قاعدة البيانات واللهجة. في هذا المثال، أطلقنا على قاعدة البيانات اسم نموذج لعبة واخترنا لغة 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);
بعد ذلك، انقر فوق الزر إنشاء وانتظر بضع ثوانٍ حتى يتم إنشاء قاعدة البيانات.
يُفترض أن تبدو صفحة إنشاء قاعدة البيانات على النحو التالي:
تحتاج الآن إلى ضبط بعض متغيرات البيئة في Cloud Shell لاستخدامها لاحقًا في التمرين المعملي الخاص بالرموز. لذا، دوِّن معرّف المثيل واضبط INSTANCE_ID وDATABASE_ID في Cloud Shell.
export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
ملخّص
في هذه الخطوة، أنشأت مثيل Spanner وقاعدة بيانات نموذج لعبة. لقد حدَّدت أيضًا المخطط الذي تستخدمه هذه اللعبة النموذجية.
التالي
بعد ذلك، ستنشر خدمة الملف الشخصي للسماح للّاعبين بالاشتراك في اللعبة!
4. نشر خدمة الملف الشخصي
نظرة عامة حول الخدمة
خدمة الملف الشخصي هي واجهة برمجة تطبيقات REST مكتوبة بلغة Go وتستفيد من إطار عمل gin.
في واجهة برمجة التطبيقات هذه، يمكن للّاعبين الاشتراك لتشغيل الألعاب. يتم إنشاء ذلك من خلال أمر 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.
في واجهة برمجة التطبيقات هذه، يتم إنشاء الألعاب وإغلاقها. عندما يتم إنشاء لعبة، يتم إسناد اللعبة إلى 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:
بعد ذلك، يمكنك إصدار أمر 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.
الاطّلاع على المباريات المفتوحة مقارنةً بالمباريات المغلقة
اللعبة المغلقة هي التي تمت تعبئة الطابع الزمني النهائي لها، في حين تكون اللعبة المفتوحة منتهية وتكون فارغة (NULL). يتم ضبط هذه القيمة عند إغلاق اللعبة.
إذًا، سيتيح لك هذا الاستعلام التحقق من عدد الألعاب المفتوحة وعدد الألعاب المغلقة:
SELECT Type, NumGames FROM
(SELECT "Open Games" as Type, count(*) as NumGames FROM games WHERE finished IS NULL
UNION ALL
SELECT "Closed Games" as Type, count(*) as NumGames FROM games WHERE finished IS NOT NULL
)
النتيجة:
|
|
|
|
|
|
التحقق من عدد اللاعبين الذين يلعبون مقابل عدم اللعب
يلعب أحد اللاعبين لعبة في حال ضبط عمود current_game. وبخلاف ذلك، لن يلعب المستخدمون حاليًا أي لعبة.
إذًا لمقارنة عدد اللاعبين الذين يلعبون ولا يلعبون حاليًا، استخدم هذا الاستعلام:
SELECT Type, NumPlayers FROM
(SELECT "Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NOT NULL
UNION ALL
SELECT "Not Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NULL
)
النتيجة:
|
|
|
|
|
|
تحديد أفضل الألعاب الفائزة
وعند إغلاق اللعبة، يتم اختيار أحد اللاعبين بشكل عشوائي ليكون الفائز. تتم زيادة إحصاءات games_won لهذا اللاعب خلال إغلاق اللعبة.
SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;
النتيجة:
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" الذي أنشأناه في خطوة الدرس التطبيقي حول الترميز باسم "إعداد مثيل Cloud Spanner".
9. تهانينا!
تهانينا، لقد نجحت في نشر نموذج لعبة على Spanner.
ما هي الخطوات التالية؟
لقد تعرفت في هذا التمرين المعملي على موضوعات مختلفة للعمل مع Spanner باستخدام برنامج تشغيل golang. ويجب أن يوفر لك أساسًا أفضل لفهم المفاهيم الهامة مثل:
- تصميم المخطط
- DML في مقابل التغيُّرات
- العمل مع Golang
ننصحك بالاطّلاع على الدرس التطبيقي حول الترميز Cloud Spanner Game Trading Post للتعرّف على مثال آخر حول استخدام Spanner كخلفية للعبتك.