Cloud Spanner: إنشاء لوحة صدارة للألعاب باستخدام Go

1. نظرة عامة

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

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

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

المعلومات التي ستطّلع عليها

  • كيفية إعداد مثيل Cloud Spanner
  • كيفية إنشاء قاعدة بيانات وجداول
  • كيفية استخدام عمود الطابع الزمني للتنفيذ
  • كيفية تحميل البيانات إلى جدول قاعدة بيانات Cloud Spanner مع الطوابع الزمنية
  • كيفية طلب بحث في قاعدة بيانات Cloud Spanner
  • كيفية حذف مثيل Cloud Spanner

كل ما ستحتاج إليه

كيف ستستخدم هذا البرنامج التعليمي؟

القراءة فقط اقرأها وأكمِل التمارين

ما هو تقييمك لتجربتك في استخدام Google Cloud Platform؟

حديث متوسط بارع

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

إعداد بيئة ذاتية

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

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

6c9406d9b014760.png

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

f708315ae07353d0.png

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

870a3cbd6541ee86.png

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

6a92c57d3250a4b3.png

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

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

15d0ef27a8fbab27.png

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

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

إعداد Google Cloud Shell

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

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

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

b532b2f19ab85dda.png

لقطة شاشة يوم 14-06-2017 في الساعة 10.13.43 مساءً.png

بعد الربط بتطبيق Cloud Shell، من المفترض أن يظهر لك أنّه قد تمت مصادقتك وأنّ المشروع سبق أن تم ضبطه على PROJECT_ID.

gcloud auth list

مخرجات الأمر

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

مخرجات الأمر

[core]
project = <PROJECT_ID>

إذا لم يتم ضبط المشروع لسبب ما، ما عليك سوى إصدار الأمر التالي:

gcloud config set project <PROJECT_ID>

هل تبحث عن PROJECT_ID؟ تحقَّق من المعرّف الذي استخدمته في خطوات الإعداد أو ابحث عنه في لوحة بيانات Cloud Console:

2485e00c1223af09.png

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

echo $GOOGLE_CLOUD_PROJECT

مخرجات الأمر

<PROJECT_ID>
  1. أخيرًا، قم بتعيين تهيئة المنطقة الافتراضية والمشروع.
gcloud config set compute/zone us-central1-f

يمكنك اختيار مجموعة متنوعة من المناطق المختلفة. لمزيد من المعلومات، راجع المناطق المناطق:

ملخّص

في هذه الخطوة، يتم إعداد البيئة.

التالي

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

3- إعداد مثيل Cloud Spanner

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

36e52f8df8e13b99.png

بعد ذلك، انقر على 19bb9864067757cb.png واملأ النموذج عن طريق إدخال اسم المثيل cloudspanner-leaderboard للمثيل، واختيار الإعدادات (اختيار مثيل إقليمي)، وضبط عدد العُقد. سنحتاج إلى عقدة واحدة فقط في هذا الدرس التطبيقي حول الترميز. بالنسبة إلى مثيلات الإنتاج وللتأهل لاتفاقية مستوى الخدمة في Cloud Spanner، ستحتاج إلى تشغيل 3 عُقد أو أكثر في مثيل Cloud Spanner.

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

dceb68e9ed3801e8.png

في الخطوة التالية، سنستخدم مكتبة عملاء Go لإنشاء قاعدة بيانات ومخطط في المثيل الجديد.

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

في هذه الخطوة، سننشئ نموذجًا لقاعدة البيانات والمخطط.

لنستخدم مكتبة عملاء Go لإنشاء جدولين؛ جدول "اللاعبين" للحصول على معلومات عن اللاعب وجدول النتائج لتخزين نتائج اللاعبين ولإجراء ذلك، سننتقل إلى خطوات إنشاء تطبيق Go console في Cloud Shell.

استنسِخ أولاً نموذج الرمز البرمجي لهذا الدرس التطبيقي من GitHub من خلال كتابة الأمر التالي في Cloud Shell:

export GO111MODULE=auto
go get -u github.com/GoogleCloudPlatform/golang-samples/spanner/...

ثم تغيير الدليل إلى "ليدربورد" الدليل الذي ستنشئ فيه التطبيق.

cd gopath/src/github.com/GoogleCloudPlatform/golang-samples/spanner/spanner_leaderboard

تتوفّر كلّ الرموز المطلوبة لهذا الدرس التطبيقي حول الترميز في دليل golang-samples/spanner/spanner_leaderboard/ الحالي كتطبيق Go قابل للتنفيذ باسم leaderboard، ويمكن استخدامه كمرجع أثناء تقدّمك في الدرس التطبيقي حول الترميز. سننشئ دليلاً جديدًا وننشئ نسخة من تطبيق "ليدربورد" على مراحل.

إنشاء دليل جديد باسم "درس تطبيقي حول الترميز" للتطبيق وتغيير الدليل إليه باستخدام الأمر التالي:

mkdir codelab && cd $_

لنقم الآن بإنشاء تطبيق Go أساسي اسمه "Leaderboard" تستخدم مكتبة عملاء Spanner لإنشاء لوحة صدارة تتألف من جدولين؛ اللاعبون والنتائج ويمكنك إجراء ذلك مباشرةً في "محرِّر Cloud Shell" باتّباع الخطوات التالية:

افتح محرِّر Cloud Shell من خلال النقر على "Open editor" (فتح المحرِّر) المحدد أدناه:

7519d016b96ca51b.png

أنشئ ملفًا باسم leaderboard.go في المجلد ~/gopath/src/github.com/GoogleCloudPlatform/golang- sample/spanner/codelab.

  • تأكّد أولاً من أنّ لديك "درس تطبيقي حول الترميز" المجلد المحدَّد في قائمة المجلدات في "محرِّر Cloud Shell".
  • ثم حدد "ملف جديد" ضمن الزر "File" في محرر Cloud Shell القائمة.
  • أدخِل "leaderboard.go". كاسم للملف الجديد.

هذا هو الملف الرئيسي للتطبيق الذي سيحتوي على رمز التطبيق والمراجع الخاصة بنا لتضمين أي ملحقات.

لإنشاء قاعدة بيانات leaderboard والجدولين Players وScores، انسخ (Ctrl + P) والصقه (Ctrl + V) رمز Go التالي في ملف leaderboard.go:

package main

import (
        "context"
        "flag"
        "fmt"
        "io"
        "log"
        "os"
        "regexp"
        "time"

        "cloud.google.com/go/spanner"
        database "cloud.google.com/go/spanner/admin/database/apiv1"

        adminpb "google.golang.org/genproto/googleapis/spanner/admin/database/v1"
)

type adminCommand func(ctx context.Context, w io.Writer, adminClient *database.DatabaseAdminClient, database string) error


func createDatabase(ctx context.Context, w io.Writer, adminClient *database.DatabaseAdminClient, db string) error {
        matches := regexp.MustCompile("^(.*)/databases/(.*)$").FindStringSubmatch(db)
        if matches == nil || len(matches) != 3 {
                return fmt.Errorf("Invalid database id %s", db)
        }
        op, err := adminClient.CreateDatabase(ctx, &adminpb.CreateDatabaseRequest{
                Parent:          matches[1],
                CreateStatement: "CREATE DATABASE `" + matches[2] + "`",
                ExtraStatements: []string{
                        `CREATE TABLE Players(
                            PlayerId INT64 NOT NULL,
                            PlayerName STRING(2048) NOT NULL
                        ) PRIMARY KEY(PlayerId)`,
                        `CREATE TABLE Scores(
                            PlayerId INT64 NOT NULL,
                            Score INT64 NOT NULL,
                            Timestamp TIMESTAMP NOT NULL
                            OPTIONS(allow_commit_timestamp=true)
                        ) PRIMARY KEY(PlayerId, Timestamp),
                        INTERLEAVE IN PARENT Players ON DELETE NO ACTION`,
                },
        })
        if err != nil {
                return err
        }
        if _, err := op.Wait(ctx); err != nil {
                return err
        }
        fmt.Fprintf(w, "Created database [%s]\n", db)
        return nil
}

func createClients(ctx context.Context, db string) (*database.DatabaseAdminClient, *spanner.Client) {
        adminClient, err := database.NewDatabaseAdminClient(ctx)
        if err != nil {
                log.Fatal(err)
        }

        dataClient, err := spanner.NewClient(ctx, db)
        if err != nil {
                log.Fatal(err)
        }

        return adminClient, dataClient
}

func run(ctx context.Context, adminClient *database.DatabaseAdminClient, dataClient *spanner.Client, w io.Writer,
        cmd string, db string, timespan int) error {
        // createdatabase command
        if cmd == "createdatabase" {
                err := createDatabase(ctx, w, adminClient, db)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }
        return nil
}

func main() {
        flag.Usage = func() {
                fmt.Fprintf(os.Stderr, `Usage: leaderboard <command> <database_name> [command_option]

        Command can be one of: createdatabase

Examples:
        leaderboard createdatabase projects/my-project/instances/my-instance/databases/example-db
                - Create a sample Cloud Spanner database along with sample tables in your project.
`)
        }

        flag.Parse()
        flagCount := len(flag.Args())
        if flagCount != 2 {
                flag.Usage()
                os.Exit(2)
        }

        cmd, db := flag.Arg(0), flag.Arg(1)
        // Set timespan to zero, as it's not currently being used
        var timespan int = 0

        ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
        defer cancel()
        adminClient, dataClient := createClients(ctx, db)
        if err := run(ctx, adminClient, dataClient, os.Stdout, cmd, db, timespan); err != nil {
                os.Exit(1)
        }
}

احفظ التغييرات التي أجريتها على ملف leaderboard.go من خلال اختيار "حفظ". ضمن الزر "File" في محرر Cloud Shell القائمة.

يمكنك استخدام ملف leaderboard.go في دليل golang-samples/spanner/spanner_leaderboard للاطّلاع على مثال حول الشكل الذي يجب أن يظهر به ملف leaderboard.go بعد إضافة الرمز لتفعيل الأمر createdatabase.

لإنشاء تطبيقك في Cloud Shell، عليك تشغيل "go معيَّن". من دليل codelab حيث يوجد ملف leaderboard.go:

go build leaderboard.go

بعد إنشاء التطبيق بنجاح، شغِّل التطبيق الناتج في Cloud Shell من خلال إدخال الأمر التالي:

./leaderboard

من المفترض أن تظهر لك نتيجة على النحو التالي:

Usage: leaderboard <command> <database_name> [command_option]

        Command can be one of: createdatabase

Examples:
        leaderboard createdatabase projects/my-project/instances/my-instance/databases/example-db
                - Create a sample Cloud Spanner database along with sample tables in your project.

من خلال هذا الردّ، يمكننا ملاحظة أنّ هذا هو تطبيق Leaderboard الذي يتضمّن حاليًا أمرًا واحدًا محتملاً: createdatabase. يتبيّن لنا أنّ الوسيطة المتوقّعة للأمر createdatabase هي سلسلة تحتوي على معرّف مثيل ومعرّف قاعدة بيانات محدّدَين.

قم الآن بتشغيل الأمر التالي. تأكَّد من استبدال my-project برقم تعريف المشروع الذي أنشأته في بداية هذا الدرس التطبيقي حول الترميز.

./leaderboard createdatabase projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard

بعد بضع ثوانٍ، من المفترض أن يظهر لك رد كالتالي:

Created database [projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard] 

في قسم النظرة العامة على قواعد بيانات Cloud Spanner في Cloud Console، من المفترض أن تظهر لك قاعدة البيانات والجداول الجديدة في القائمة الجانبية اليمنى.

a12fa65e352836b1.png

في الخطوة التالية سنحدث تطبيقنا لتحميل بعض البيانات إلى قاعدة بياناتك الجديدة.

5- تحميل البيانات

لدينا الآن قاعدة بيانات تسمى leaderboard تحتوي على جدولين؛ Players وScores لنستخدم الآن مكتبة عملاء Go لملء جدول Players باللاعبين وجدول Scores بنتائج عشوائية لكل لاعب.

إذا لم يكن التطبيق مفتوحًا، افتح محرِّر Cloud Shell بالنقر على الرمز المميّز أدناه:

7519d016b96ca51b.png

بعد ذلك، عدِّل ملف leaderboard.go في "محرِّر Cloud Shell" لإضافة الأمر insertplayers الذي يمكن استخدامه لإدراج 100 لاعب في جدول Players. سنضيف أيضًا الأمر insertscores الذي يمكن استخدامه لإدراج 4 نتائج عشوائية في جدول Scores لكل لاعب في جدول Players.

حدِّث أولاً القسم imports أعلى الملف leaderboard.go، مع استبدال العناصر الموجودة حاليًا، بحيث يظهر على النحو التالي بعد الانتهاء:

import (
        "context"
        "flag"
        "fmt"
        "io"
        "log"
        "math/rand"
        "os"
        "regexp"
        "time"

        "cloud.google.com/go/spanner"
        database "cloud.google.com/go/spanner/admin/database/apiv1"

        "google.golang.org/api/iterator"
        adminpb "google.golang.org/genproto/googleapis/spanner/admin/database/v1"
)

بعد ذلك، أضف نوع أوامر جديد إلى جانب قائمة بالأوامر في أعلى الملف، أسفل السطر الذي يبدأ مباشرةً بـ "اكتب adminCommand ..." بحيث بمجرد الانتهاء، يجب أن يبدو على النحو التالي:

type adminCommand func(ctx context.Context, w io.Writer, adminClient *database.DatabaseAdminClient, database string) error

type command func(ctx context.Context, w io.Writer, client *spanner.Client) error
var (
        commands = map[string]command{
                "insertplayers": insertPlayers,
                "insertscores":  insertScores,
        }
)

بعد ذلك، أضف دوال insertPlayers التالية ودوال insertScores أسفل دالة createdatabase() الحالية:

func insertPlayers(ctx context.Context, w io.Writer, client *spanner.Client) error {
        // Get number of players to use as an incrementing value for each PlayerName to be inserted
        stmt := spanner.Statement{
                SQL: `SELECT Count(PlayerId) as PlayerCount FROM Players`,
        }
        iter := client.Single().Query(ctx, stmt)
        defer iter.Stop()
        row, err := iter.Next()
        if err != nil {
                return err
        }
        var numberOfPlayers int64 = 0
        if err := row.Columns(&numberOfPlayers); err != nil {
                return err
        }
        // Initialize values for random PlayerId
        rand.Seed(time.Now().UnixNano())
        min := 1000000000
        max := 9000000000
        // Insert 100 player records into the Players table
        _, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
                stmts := []spanner.Statement{}
                for i := 1; i <= 100; i++ {
                        numberOfPlayers++
                        playerID := rand.Intn(max-min) + min
                        playerName := fmt.Sprintf("Player %d", numberOfPlayers)
                        stmts = append(stmts, spanner.Statement{
                                SQL: `INSERT INTO Players
                                                (PlayerId, PlayerName)
                                                VALUES (@playerID, @playerName)`,
                                Params: map[string]interface{}{
                                        "playerID":   playerID,
                                        "playerName": playerName,
                                },
                        })
                }
                _, err := txn.BatchUpdate(ctx, stmts)
                if err != nil {
                        return err
                }
                return nil
        })
        fmt.Fprintf(w, "Inserted players \n")
        return nil
}

func insertScores(ctx context.Context, w io.Writer, client *spanner.Client) error {
        playerRecordsFound := false
        // Create slice for insert statements
        stmts := []spanner.Statement{}
        // Select all player records
        stmt := spanner.Statement{SQL: `SELECT PlayerId FROM Players`}
        iter := client.Single().Query(ctx, stmt)
        defer iter.Stop()
        // Insert 4 score records into the Scores table for each player in the Players table
        for {
                row, err := iter.Next()
                if err == iterator.Done {
                        break
                }
                if err != nil {
                        return err
                }
                playerRecordsFound = true
                var playerID int64
                if err := row.ColumnByName("PlayerId", &playerID); err != nil {
                        return err
                }
                // Initialize values for random score and date
                rand.Seed(time.Now().UnixNano())
                min := 1000
                max := 1000000
                for i := 0; i < 4; i++ {
                        // Generate random score between 1,000 and 1,000,000
                        score := rand.Intn(max-min) + min
                        // Generate random day within the past two years
                        now := time.Now()
                        endDate := now.Unix()
                        past := now.AddDate(0, -24, 0)
                        startDate := past.Unix()
                        randomDateInSeconds := rand.Int63n(endDate-startDate) + startDate
                        randomDate := time.Unix(randomDateInSeconds, 0)
                        // Add insert statement to stmts slice
                        stmts = append(stmts, spanner.Statement{
                                SQL: `INSERT INTO Scores
                                                (PlayerId, Score, Timestamp)
                                                VALUES (@playerID, @score, @timestamp)`,
                                Params: map[string]interface{}{
                                        "playerID":  playerID,
                                        "score":     score,
                                        "timestamp": randomDate,
                                },
                        })
                }

        }
        if !playerRecordsFound {
                fmt.Fprintln(w, "No player records currently exist. First insert players then insert scores.")
        } else {
                _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
                        // Commit insert statements for all scores to be inserted as a single transaction
                        _, err := txn.BatchUpdate(ctx, stmts)
                        return err
                })
                if err != nil {
                        return err
                }
                fmt.Fprintln(w, "Inserted scores")
        }
        return nil
}

بعد ذلك، لتفعيل الأمر insert، أضِف الرمز التالي إلى قسم "تشغيل" التطبيق في تطبيقك. أسفل عبارة المعالجة createdatabase، لتحل محل عبارة return nil :

        // insert and query commands
        cmdFn := commands[cmd]
        if cmdFn == nil {
                flag.Usage()
                os.Exit(2)
        }
        err := cmdFn(ctx, w, dataClient)
        if err != nil {
                fmt.Fprintf(w, "%s failed with %v", cmd, err)
        }
        return err

بعد الانتهاء، من المفترض أن تظهر الدالة run على النحو التالي:

func run(ctx context.Context, adminClient *database.DatabaseAdminClient, dataClient *spanner.Client, w io.Writer,
        cmd string, db string, timespan int) error {
        // createdatabase command
        if cmd == "createdatabase" {
                err := createDatabase(ctx, w, adminClient, db)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }

        // insert and query commands
        cmdFn := commands[cmd]
        if cmdFn == nil {
                flag.Usage()
                os.Exit(2)
        }
        err := cmdFn(ctx, w, dataClient)
        if err != nil {
                fmt.Fprintf(w, "%s failed with %v", cmd, err)
        }
        return err
}

الخطوة الأخيرة لإكمال عملية إضافة "insert" إلى إضافة نص مساعدة لـ "insertplayers" و"insertscores" إلى الدالة flag.Usage(). أضِف نص المساعدة التالي إلى الدالة flag.Usage() لتضمين نص مساعدة لأوامر الإدراج:

أضِف الأمرَين إلى قائمة الأوامر المحتملة:

Command can be one of: createdatabase, insertplayers, insertscores

وأضِف نص المساعدة الإضافي هذا أسفل نص المساعدة للأمر createdatabase.

        leaderboard insertplayers projects/my-project/instances/my-instance/databases/example-db
                - Insert 100 sample Player records into the database.
        leaderboard insertscores projects/my-project/instances/my-instance/databases/example-db
                - Insert sample score data into Scores sample Cloud Spanner database table.

احفظ التغييرات التي أجريتها على ملف leaderboard.go من خلال اختيار "حفظ". ضمن الزر "File" في محرر Cloud Shell القائمة.

يمكنك استخدام ملف leaderboard.go في دليل golang-samples/spanner/spanner_leaderboard للاطّلاع على مثال حول الشكل الذي يجب أن يظهر به ملف leaderboard.go بعد إضافة الرمز لتفعيل الأمرَين insertplayers وinsertscores.

والآن، لننشئ التطبيق ونشغِّله للتأكّد من تضمين الأمرَين الجديدَين insertplayers وinsertscores في قائمة الأوامر المحتملة في التطبيق. شغِّل الأمر التالي لإنشاء التطبيق:

go build leaderboard.go

شغّل التطبيق الناتج في Cloud Shell من خلال إدخال الأمر التالي:

./leaderboard

من المفترض أن يظهر لك الأمران insertplayers وinsertscores مضمَّنان الآن في الناتج التلقائي للتطبيق:

Usage: leaderboard <command> <database_name> [command_option]

        Command can be one of: createdatabase, insertplayers, insertscores

Examples:
        leaderboard createdatabase projects/my-project/instances/my-instance/databases/example-db
                - Create a sample Cloud Spanner database along with sample tables in your project.
        leaderboard insertplayers projects/my-project/instances/my-instance/databases/example-db
                - Insert 100 sample Player records into the database.
        leaderboard insertscores projects/my-project/instances/my-instance/databases/example-db
                - Insert sample score data into Scores sample Cloud Spanner database table.

لنقم الآن بتشغيل الأمر insertplayers باستخدام قيم الوسيطة نفسها التي استخدمناها عند استدعينا الأمر createdatabase. تأكَّد من استبدال my-project برقم تعريف المشروع الذي أنشأته في بداية هذا الدرس التطبيقي حول الترميز.

./leaderboard insertplayers projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard

بعد بضع ثوانٍ، من المفترض أن يظهر لك رد كالتالي:

Inserted players

لنستخدم الآن مكتبة عملاء Go لملء جدول Scores بأربع نتائج عشوائية بالإضافة إلى طوابع زمنية لكل لاعب في جدول Players.

تم تعريف عمود Timestamp في جدول Scores على أنّه "طابع زمني للالتزام". عبر عبارة SQL التالية التي تم تنفيذها عند تشغيل الأمر create سابقًا:

CREATE TABLE Scores(
  PlayerId INT64 NOT NULL,
  Score INT64 NOT NULL,
  Timestamp TIMESTAMP NOT NULL OPTIONS(allow_commit_timestamp=true)
) PRIMARY KEY(PlayerId, Timestamp),
    INTERLEAVE IN PARENT Players ON DELETE NO ACTION

لاحِظ السمة OPTIONS(allow_commit_timestamp=true). يجعل هذا الإجراء Timestamp "طابعًا زمنيًا للالتزام". وتمكين ملؤه تلقائيًا بالطابع الزمني الدقيق للمعاملة لعمليات INSERT وUPDATE في صف جدول معين.

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

لنقم الآن بتشغيل الأمر insertscores باستخدام قيم الوسيطة نفسها التي استخدمناها عند استدعينا الأمر insertplayers. تأكَّد من استبدال my-project برقم تعريف المشروع الذي أنشأته في بداية هذا الدرس التطبيقي حول الترميز.

./leaderboard insertscores projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard

بعد بضع ثوانٍ، من المفترض أن يظهر لك رد كالتالي:

Inserted scores

يؤدي تشغيل الدالة insertScores إلى استخدام مقتطف الرمز التالي لإدراج طابع زمني تم إنشاؤه عشوائيًا مع تاريخ ووقت يقع في الماضي:

now := time.Now()
endDate := now.Unix()
past := now.AddDate(0, -24, 0)
startDate := past.Unix()
randomDateInSeconds := rand.Int63n(endDate-startDate) + startDate
randomDate := time.Unix(randomDateInSeconds, 0)
stmts = append(stmts, spanner.Statement{
        SQL: `INSERT INTO Scores
              (PlayerId, Score, Timestamp)
                 VALUES (@playerID, @score, @timestamp)`,
        Params: map[string]interface{}{
                "playerID":  playerID,
                "score":     score,
                "timestamp": randomDate,
        },
})

لتعبئة عمود Timestamp تلقائيًا بالطابع الزمني عند ضبط الزر "إدراج" بالضبط تحدث العملية، يمكنك بدلاً من ذلك إدراج spanner.CommitTimestamp ثابتة كما هو الحال في مقتطف الرمز التالي:

...
stmts = append(stmts, spanner.Statement{
        SQL: `INSERT INTO Scores
              (PlayerId, Score, Timestamp)
                 VALUES (@playerID, @score, @timestamp)`,
        Params: map[string]interface{}{
                "playerID":  playerID,
                "score":     score,
                "timestamp": spanner.CommitTimestamp,
        },
})

الآن وبعد أن أكملنا عملية تحميل البيانات، علينا التحقّق من القيم التي كتبناها للتو إلى جداولنا الجديدة في قسم Cloud Spanner من Cloud Console. اختَر أولاً قاعدة بيانات leaderboard ثم اختَر الجدول Players. انقر على علامة التبويب Data. من المفترض أن يظهر لك أنّ لديك بيانات في العمودَين PlayerId وPlayerName في الجدول.

86dc5b927809a4ec.png

بعد ذلك، لنتحقق من أن جدول "النتائج" يحتوي أيضًا على بيانات من خلال النقر على جدول Scores واختيار علامة التبويب Data. من المفترض أن يظهر لك أنّ لديك بيانات في الأعمدة PlayerId وTimestamp وScore في الجدول.

87c8610c92d3c612.png

أحسنت! لنعدّل تطبيقنا لتشغيل بعض طلبات البحث التي يمكننا استخدامها لإنشاء قائمة الصدارة للألعاب.

6- تنفيذ طلبات بحث في قائمة الصدارة

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

  1. مَن هم اللاعبين ضمن "أفضل عشرة" من جميع الأوقات؟
  2. مَن هم اللاعبين ضمن "أفضل عشرة" من العام؟
  3. مَن هم اللاعبين ضمن "أفضل عشرة" من الشهر؟
  4. مَن هم اللاعبين ضمن "أفضل عشرة" من الأسبوع؟

لنقم بتحديث تطبيقنا لتشغيل استعلامات SQL (لغة الاستعلام البنيوية) التي سوف تجيب على هذه الأسئلة.

سنضيف الأمر query والأمر queryWithTimespan الذي يوفر طريقة لتشغيل الاستعلامات للإجابة عن الأسئلة، وسينتج عن ذلك المعلومات المطلوبة لقائمة الصدارة.

يمكنك تعديل ملف leaderboard.go في "محرِّر Cloud Shell" لتحديث التطبيق من خلال إضافة الأمر query والأمر queryWithTimespan. سنضيف أيضًا دالة مساعدة formatWithCommas لتنسيق نتائجنا باستخدام الفواصل.

حدِّث أولاً القسم imports أعلى الملف leaderboard.go، مع استبدال العناصر الموجودة حاليًا، بحيث يظهر على النحو التالي بعد الانتهاء:

import (
        "bytes"
        "context"
        "flag"
        "fmt"
        "io"
        "log"
        "math/rand"
        "os"
        "regexp"
        "strconv"
        "time"

        "cloud.google.com/go/spanner"
        database "cloud.google.com/go/spanner/admin/database/apiv1"

        "google.golang.org/api/iterator"
        adminpb "google.golang.org/genproto/googleapis/spanner/admin/database/v1"
)

أضِف بعد ذلك الدالتَين التاليتَين والدالة المساعدة أسفل طريقة insertScores الحالية:

func query(ctx context.Context, w io.Writer, client *spanner.Client) error {
        stmt := spanner.Statement{
                SQL: `SELECT p.PlayerId, p.PlayerName, s.Score, s.Timestamp
                        FROM Players p
                        JOIN Scores s ON p.PlayerId = s.PlayerId
                        ORDER BY s.Score DESC LIMIT 10`}
        iter := client.Single().Query(ctx, stmt)
        defer iter.Stop()
        for {
                row, err := iter.Next()
                if err == iterator.Done {
                        return nil
                }
                if err != nil {
                        return err
                }
                var playerID, score int64
                var playerName string
                var timestamp time.Time
                if err := row.Columns(&playerID, &playerName, &score, &timestamp); err != nil {
                        return err
                }
                fmt.Fprintf(w, "PlayerId: %d  PlayerName: %s  Score: %s  Timestamp: %s\n",
                        playerID, playerName, formatWithCommas(score), timestamp.String()[0:10])
        }
}

func queryWithTimespan(ctx context.Context, w io.Writer, client *spanner.Client, timespan int) error {
        stmt := spanner.Statement{
                SQL: `SELECT p.PlayerId, p.PlayerName, s.Score, s.Timestamp
                                FROM Players p
                                JOIN Scores s ON p.PlayerId = s.PlayerId 
                                WHERE s.Timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @Timespan HOUR)
                                ORDER BY s.Score DESC LIMIT 10`,
                Params: map[string]interface{}{"Timespan": timespan},
        }
        iter := client.Single().Query(ctx, stmt)
        defer iter.Stop()
        for {
                row, err := iter.Next()
                if err == iterator.Done {
                        return nil
                }
                if err != nil {
                        return err
                }
                var playerID, score int64
                var playerName string
                var timestamp time.Time
                if err := row.Columns(&playerID, &playerName, &score, &timestamp); err != nil {
                        return err
                }
                fmt.Fprintf(w, "PlayerId: %d  PlayerName: %s  Score: %s  Timestamp: %s\n",
                        playerID, playerName, formatWithCommas(score), timestamp.String()[0:10])
        }
}

func formatWithCommas(n int64) string {
        numberAsString := strconv.FormatInt(n, 10)
        numberLength := len(numberAsString)
        if numberLength < 4 {
                return numberAsString
        }
        var buffer bytes.Buffer
        comma := []rune(",")
        bufferPosition := numberLength % 3
        if (bufferPosition) > 0 {
                bufferPosition = 3 - bufferPosition
        }
        for i := 0; i < numberLength; i++ {
                if bufferPosition == 3 {
                        buffer.WriteRune(comma[0])
                        bufferPosition = 0
                }
                bufferPosition++
                buffer.WriteByte(numberAsString[i])
        }
        return buffer.String()
}

أضِف "طلب بحث" في أعلى الملف leaderboard.go. كخيار أمر واحد في المتغير commands، أسفل الخيار "insertscores": insertScores" مباشرةً بحيث يظهر المتغير commands على النحو التالي:

var (
        commands = map[string]command{
                "insertplayers": insertPlayers,
                "insertscores":  insertScores,
                "query":         query,
        }
)

أضِف بعد ذلك "queryWithTimespan" كخيار أمر داخل الدالة run، أسفل "createdatabase" وفوق "إدراج واستعلام" قسم معالجة الأوامر:

        // querywithtimespan command
        if cmd == "querywithtimespan" {
                err := queryWithTimespan(ctx, w, dataClient, timespan)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }

بعد الانتهاء، من المفترض أن تظهر الدالة run على النحو التالي:

func run(ctx context.Context, adminClient *database.DatabaseAdminClient, dataClient *spanner.Client, w io.Writer,
        cmd string, db string, timespan int) error {
        // createdatabase command
        if cmd == "createdatabase" {
                err := createDatabase(ctx, w, adminClient, db)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }

        // querywithtimespan command
        if cmd == "querywithtimespan" {
                if timespan == 0 {
                        flag.Usage()
                        os.Exit(2)
                }
                err := queryWithTimespan(ctx, w, dataClient, timespan)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }

        // insert and query commands
        cmdFn := commands[cmd]
        if cmdFn == nil {
                flag.Usage()
                os.Exit(2)
        }
        err := cmdFn(ctx, w, dataClient)
        if err != nil {
                fmt.Fprintf(w, "%s failed with %v", cmd, err)
        }
        return err
}

بعد ذلك، لتفعيل الأمر queryWithTimespan، عليك تعديل كتلة الرموز التجريبية flag.Parse() في "الرئيسي" لتطبيقك. بحيث يبدو كما يلي:

        flag.Parse()
        flagCount := len(flag.Args())
        if flagCount < 2 || flagCount > 3 {
                flag.Usage()
                os.Exit(2)
        }

        cmd, db := flag.Arg(0), flag.Arg(1)
        // If query timespan flag is specified, parse to int
        var timespan int = 0
        if flagCount == 3 {
                parsedTimespan, err := strconv.Atoi(flag.Arg(2))
                if err != nil {
                        fmt.Println(err)
                        os.Exit(2)
                }
                timespan = parsedTimespan
        }

        ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
        defer cancel()
        adminClient, dataClient := createClients(ctx, db)
        if err := run(ctx, adminClient, dataClient, os.Stdout, cmd, db, timespan); err != nil {
                os.Exit(1)
        }

الخطوة الأخيرة لإكمال إضافة "طلب البحث" بشكل أفضل إلى تطبيقك، هي إضافة نص مساعدة لطلب البحث و"querywithtimespan" إلى الدالة flag.Usage(). أضِف سطور الرمز التالية إلى الدالة flag.Usage() لتضمين نص مساعدة لأوامر طلب البحث:

أضف اثنين من "استعلامات البحث" إلى قائمة الأوامر المحتملة:

Command can be one of: createdatabase, insertplayers, insertscores, query, querywithtimespan

وأضِف نص المساعدة الإضافي هذا أسفل نص المساعدة للأمر insertscores.

        leaderboard query projects/my-project/instances/my-instance/databases/example-db
                - Query players with top ten scores of all time.
        leaderboard querywithtimespan projects/my-project/instances/my-instance/databases/example-db 168
                - Query players with top ten scores within a timespan specified in hours.

احفظ التغييرات التي أجريتها على ملف leaderboard.go من خلال اختيار "حفظ". ضمن الزر "File" في محرر Cloud Shell القائمة.

يمكنك استخدام ملف leaderboard.go في دليل golang-samples/spanner/spanner_leaderboard للاطّلاع على مثال حول الشكل الذي يجب أن يظهر به ملف leaderboard.go بعد إضافة الرمز لتفعيل الأمرَين query وquerywithtimespan.

والآن، لننشئ التطبيق ونشغِّله للتأكّد من تضمين الأمرَين الجديدَين query وquerywithtimespan في قائمة الأوامر المحتملة في التطبيق.

شغّل الأمر التالي في Cloud Shell لإنشاء التطبيق:

go build leaderboard.go

شغّل التطبيق الناتج في Cloud Shell من خلال إدخال الأمر التالي:

./leaderboard

من المفترَض أن يظهر لك الأمران query وquerywithtimespan المضمّنان الآن في الناتج التلقائي للتطبيق كخيار أمر جديد:

Usage: leaderboard <command> <database_name> [command_option]

        Command can be one of: createdatabase, insertplayers, insertscores, query, querywithtimespan

Examples:
        leaderboard createdatabase projects/my-project/instances/my-instance/databases/example-db
                - Create a sample Cloud Spanner database along with sample tables in your project.
        leaderboard insertplayers projects/my-project/instances/my-instance/databases/example-db
                - Insert 100 sample Player records into the database.
        leaderboard insertscores projects/my-project/instances/my-instance/databases/example-db
                - Insert sample score data into Scores sample Cloud Spanner database table.
        leaderboard query projects/my-project/instances/my-instance/databases/example-db
                - Query players with top ten scores of all time.
        leaderboard querywithtimespan projects/my-project/instances/my-instance/databases/example-db 168
                - Query players with top ten scores within a timespan specified in hours.

يمكنك أن ترى من الإجابة أنه يمكننا استخدام الأمر query للحصول على قائمة بـ "أهم عشرة" لاعبين في جميع الأوقات. يتضح لنا أيضًا أنّ الأمر querywithtimespan يتيح لنا تحديد فترة زمنية بعدد الساعات لاستخدامها في فلترة السجلّات استنادًا إلى قيمتها في العمود Timestamp ضمن جدول Scores.

لننفِّذ الأمر query باستخدام قيم الوسيطات نفسها التي استخدمناها عند تشغيل الأمر create. تأكَّد من استبدال my-project برقم تعريف المشروع الذي أنشأته في بداية هذا الدرس التطبيقي حول الترميز.

./leaderboard query  projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard

من المفترض أن يظهر لك رد يتضمن "أفضل عشرة" لاعبين من جميع العصور مثل ما يلي:

PlayerId: 4018687297  PlayerName: Player 83  Score: 999,618  Timestamp: 2017-07-01
PlayerId: 4018687297  PlayerName: Player 83  Score: 998,956  Timestamp: 2017-09-02
PlayerId: 4285713246  PlayerName: Player 51  Score: 998,648  Timestamp: 2017-12-01
PlayerId: 5267931774  PlayerName: Player 49  Score: 997,733  Timestamp: 2017-11-09
PlayerId: 1981654448  PlayerName: Player 35  Score: 997,480  Timestamp: 2018-12-06
PlayerId: 4953940705  PlayerName: Player 87  Score: 995,184  Timestamp: 2018-09-14
PlayerId: 2456736905  PlayerName: Player 84  Score: 992,881  Timestamp: 2017-04-14
PlayerId: 8234617611  PlayerName: Player 19  Score: 992,399  Timestamp: 2017-12-27
PlayerId: 1788051688  PlayerName: Player 76  Score: 992,265  Timestamp: 2018-11-22
PlayerId: 7127686505  PlayerName: Player 97  Score: 992,038  Timestamp: 2017-12-02

لنُشغِّل الآن الأمر querywithtimespan مع الوسيطات اللازمة للاستعلام عن "أهم عشرة". أفضل لاعب في العام من خلال تحديد "فترة زمنية" يساوي عدد الساعات في السنة وهو 8760. تأكَّد من استبدال my-project برقم تعريف المشروع الذي أنشأته في بداية هذا الدرس التطبيقي حول الترميز.

./leaderboard querywithtimespan projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard 8760

من المفترض أن يظهر لك رد يتضمن "أفضل عشرة" أفضل لاعبي العام كما يلي:

PlayerId: 1981654448  PlayerName: Player 35  Score: 997,480  Timestamp: 2018-12-06
PlayerId: 4953940705  PlayerName: Player 87  Score: 995,184  Timestamp: 2018-09-14
PlayerId: 1788051688  PlayerName: Player 76  Score: 992,265  Timestamp: 2018-11-22
PlayerId: 6862349579  PlayerName: Player 30  Score: 990,877  Timestamp: 2018-09-14
PlayerId: 5529627211  PlayerName: Player 16  Score: 989,142  Timestamp: 2018-03-30
PlayerId: 9743904155  PlayerName: Player 1  Score: 988,765  Timestamp: 2018-05-30
PlayerId: 6809119884  PlayerName: Player 7  Score: 986,673  Timestamp: 2018-05-16
PlayerId: 2132710638  PlayerName: Player 54  Score: 983,108  Timestamp: 2018-09-11
PlayerId: 2320093590  PlayerName: Player 79  Score: 981,373  Timestamp: 2018-05-07
PlayerId: 9554181430  PlayerName: Player 80  Score: 981,087  Timestamp: 2018-06-21

لنقم الآن بتشغيل الأمر querywithtimespan للاستعلام عن "أعلى عشرة" لاعب الشهر من خلال تحديد "فترة زمنية" يساوي عدد الساعات في الشهر وهو 730. تأكَّد من استبدال my-project برقم تعريف المشروع الذي أنشأته في بداية هذا الدرس التطبيقي حول الترميز.

./leaderboard querywithtimespan projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard 730

من المفترض أن يظهر لك رد يتضمن "أفضل عشرة" اللاعبين الأفضل في الشهر كما يلي:

PlayerId: 3869829195  PlayerName: Player 69  Score: 949,686  Timestamp: 2019-02-19
PlayerId: 7448359883  PlayerName: Player 20  Score: 938,998  Timestamp: 2019-02-07
PlayerId: 1981654448  PlayerName: Player 35  Score: 929,003  Timestamp: 2019-02-22
PlayerId: 9336678658  PlayerName: Player 44  Score: 914,106  Timestamp: 2019-01-27
PlayerId: 6968576389  PlayerName: Player 40  Score: 898,041  Timestamp: 2019-02-21
PlayerId: 5529627211  PlayerName: Player 16  Score: 896,433  Timestamp: 2019-01-29
PlayerId: 9395039625  PlayerName: Player 59  Score: 879,495  Timestamp: 2019-02-09
PlayerId: 2094604854  PlayerName: Player 39  Score: 860,434  Timestamp: 2019-02-01
PlayerId: 9395039625  PlayerName: Player 59  Score: 849,955  Timestamp: 2019-02-21
PlayerId: 4285713246  PlayerName: Player 51  Score: 805,654  Timestamp: 2019-02-02

لنقم الآن بتشغيل الأمر querywithtimespan للاستعلام عن "أعلى عشرة" لاعبو الأسبوع من خلال تحديد "فترة زمنية" يساوي عدد الساعات في الأسبوع وهو 168. تأكَّد من استبدال my-project برقم تعريف المشروع الذي أنشأته في بداية هذا الدرس التطبيقي حول الترميز.

./leaderboard querywithtimespan  projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard 168

من المفترض أن يظهر لك رد يتضمن "أفضل عشرة" لاعبي الأسبوع مثل ما يلي:

PlayerId: 3869829195  PlayerName: Player 69  Score: 949,686  Timestamp: 2019-02-19
PlayerId: 1981654448  PlayerName: Player 35  Score: 929,003  Timestamp: 2019-02-22
PlayerId: 6968576389  PlayerName: Player 40  Score: 898,041  Timestamp: 2019-02-21
PlayerId: 9395039625  PlayerName: Player 59  Score: 849,955  Timestamp: 2019-02-21
PlayerId: 5954045812  PlayerName: Player 8  Score: 795,639  Timestamp: 2019-02-22
PlayerId: 3889939638  PlayerName: Player 71  Score: 775,252  Timestamp: 2019-02-21
PlayerId: 5529627211  PlayerName: Player 16  Score: 604,695  Timestamp: 2019-02-19
PlayerId: 9006728426  PlayerName: Player 3  Score: 457,208  Timestamp: 2019-02-22
PlayerId: 8289497066  PlayerName: Player 58  Score: 227,697  Timestamp: 2019-02-20
PlayerId: 8065482904  PlayerName: Player 99  Score: 198,429  Timestamp: 2019-02-24

ممتاز!

والآن بعد إضافة السجلات، ستتمكن خدمة Cloud Spanner من تغيير حجم قاعدة البيانات إلى الحجم الذي تحتاج إليه. بغض النظر عن حجم قاعدة بياناتك، يمكن أن يستمر تغيير قائمة الصدارة في لعبتك بدقة باستخدام Cloud Spanner وتقنية Truetime الخاصة بها.

7. تنظيف

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

8. تهانينا!

المواضيع التي تناولناها:

  • مثيلات Google Cloud Spanner وقواعد البيانات ومخطط الجدول لقائمة الصدارة
  • طريقة إنشاء تطبيق Go console
  • كيفية إنشاء قاعدة بيانات Spanner وTables باستخدام مكتبة عملاء Go
  • كيفية تحميل البيانات إلى قاعدة بيانات Spanner باستخدام مكتبة عميل Go
  • كيفية الاستعلام عن "أعلى عشرة" نتائج من بياناتك باستخدام الطوابع الزمنية للتنفيذ في Spanner ومكتبة برامج Go

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

يُرجى إرسال ملاحظاتك إلينا

  • يُرجى تخصيص بعض الوقت لإكمال الاستطلاع القصير جدًا.