1. مقدمة
Cloud Spanner هي خدمة قواعد بيانات ارتباطية مُدارة بالكامل وقابلة للتوسّع أفقيًا وموزّعة على مستوى العالم، وتوفّر معاملات ACID ودلالات SQL بدون التنازل عن الأداء العالي ومدى التوفّر العالي.
تجعل هذه الميزات من Spanner خيارًا مناسبًا لبنية الألعاب التي تريد إتاحة قاعدة لاعبين عالمية أو التي تهتم باتساق البيانات.
في هذا التمرين العملي، ستنشئ خدمتَين بلغة Go تتفاعلان مع قاعدة بيانات إقليمية في Spanner للسماح للاعبين بالاشتراك وبدء اللعب.

بعد ذلك، ستنشئ بيانات باستخدام إطار عمل تحميل Python Locust.io لمحاكاة اللاعبين الذين يسجّلون ويشغّلون اللعبة. بعد ذلك، ستطلب من Spanner تحديد عدد اللاعبين الذين يلعبون وبعض الإحصاءات حول المباريات التي فاز بها اللاعبون مقارنةً بالمباريات التي لعبوها.
أخيرًا، ستنظّف الموارد التي تم إنشاؤها في هذا الدرس التطبيقي.
ما ستنشئه
في هذا الدرس التطبيقي، ستنفّذ ما يلي:
- إنشاء مثيل Spanner
- نشر خدمة ملفات شخصية مكتوبة بلغة Go للتعامل مع عملية اشتراك اللاعبين
- يمكنك نشر خدمة "البحث عن لاعبين" مكتوبة بلغة Go لتحديد اللاعبين في المباريات وتحديد الفائزين وتعديل إحصاءات اللاعبين في المباريات.
أهداف الدورة التعليمية
- كيفية إعداد مثيل 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، وهي بيئة سطر أوامر تعمل في السحابة الإلكترونية.
يتم تحميل هذا الجهاز الظاهري المستند إلى Debian بجميع أدوات التطوير التي تحتاج إليها. توفّر هذه الخدمة دليلًا منزليًا دائمًا بسعة 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
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
في قائمة الهمبرغر في أعلى يمين الصفحة
أو ابحث عن Spanner بالضغط على "/" وكتابة "Spanner"

بعد ذلك، انقر على
واملأ النموذج عن طريق إدخال اسم الجهاز الظاهري cloudspanner-gaming لجهازك الظاهري، واختَر إعدادًا (اختَر جهازًا ظاهريًا إقليميًا، مثل us-central1)، واضبط عدد العُقد. في هذا الدرس التطبيقي، سنحتاج إلى 500 processing units فقط.
أخيرًا، انقر على "إنشاء" وستصبح لديك في غضون ثوانٍ آلة Cloud Spanner الافتراضية تحت تصرّفك.

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

يمكن للاعبين المشاركة في العديد من المباريات على مدار الوقت، ولكن في مباراة واحدة فقط في كل مرة. يتضمّن اللاعبون أيضًا إحصاءات كـ نوع بيانات JSON لتتبُّع الإحصاءات المهمة، مثل games_played وgames_won. وبما أنّه قد تتم إضافة إحصاءات أخرى لاحقًا، فإنّ هذا العمود لا يتضمّن مخططًا للاعبين.
تتتبّع الألعاب اللاعبين المشاركين باستخدام نوع بيانات ARRAY في Spanner. لا تتم تعبئة سمات الفائز والنتيجة النهائية في المباراة إلا بعد إغلاقها.
هناك مفتاح خارجي واحد لضمان أنّ current_game الخاص باللاعب هو لعبة صالحة.
الآن، أنشئ قاعدة البيانات من خلال النقر على "إنشاء قاعدة بيانات" (Create Database) في النظرة العامة على المثيل:

ثم املأ التفاصيل. الخيارات المهمة هي اسم قاعدة البيانات واللغة. في هذا المثال، سمّينا قاعدة البيانات 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);
بعد ذلك، انقر على زر الإنشاء وانتظِر بضع ثوانٍ إلى أن يتم إنشاء قاعدة البيانات.
يجب أن تبدو صفحة إنشاء قاعدة البيانات على النحو التالي:

الآن، عليك ضبط بعض متغيّرات البيئة في Cloud Shell لاستخدامها لاحقًا في تجربة البرمجة. لذا، احفظ instance-id واضبط INSTANCE_ID وDATABASE_ID في Cloud Shell.

export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
ملخّص
في هذه الخطوة، أنشأت مثيلاً من Spanner وقاعدة بيانات sample-game. لقد حدّدت أيضًا المخطط الذي تستخدمه هذه اللعبة النموذجية.
التالي
بعد ذلك، ستنشر خدمة الملف الشخصي للسماح للاعبين بالاشتراك للعب اللعبة.
4. تفعيل خدمة الملفات الشخصية
نظرة عامة على الخدمة
خدمة الملف الشخصي هي واجهة برمجة تطبيقات REST مكتوبة بلغة Go تستخدم إطار عمل Gin.

في واجهة برمجة التطبيقات هذه، يمكن للاعبين الاشتراك للعب الألعاب. يتم إنشاء هذا الحساب باستخدام أمر 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.

في واجهة برمجة التطبيقات هذه، يتم إنشاء الألعاب وإغلاقها. عند إنشاء لعبة، يتم تعيين 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:

بعد ذلك، أصدِر أمر 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.

التحقّق من الألعاب المفتوحة والمغلقة
اللعبة المغلقة هي التي تم ملء الطابع الزمني 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
)
النتيجة:
|
|
|
|
|
|
التحقّق من عدد اللاعبين الذين يلعبون مقابل عدد اللاعبين الذين لا يلعبون
يكون اللاعب بصدد لعب إحدى الألعاب إذا تم ضبط عمود 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 كخادم خلفي للعبة.