תחילת העבודה עם פיתוח משחקים ב-Cloud Spanner

1. מבוא

Cloud Spanner הוא שירות מנוהל של מסד נתונים רלציוני בקנה מידה אופקי שאפשר להתאים לעומס, שזמין בכל העולם. השירות מספק טרנזקציות ACID וסמנטיקה של SQL, בלי לוותר על ביצועים וזמינות גבוהה.

בזכות התכונות האלה, Spanner מתאים מאוד לארכיטקטורה של משחקים שרוצים להפעיל בהם בסיס שחקנים גלובלי או שיש להם חששות לגבי עקביות הנתונים.

בשיעור ה-Lab הזה תיצרו שני שירותי Go שיוצרים אינטראקציה עם מסד נתונים אזורי של Spanner כדי לאפשר לשחקנים להירשם ולהתחיל לשחק.

413fdd57bb0b68bc.png

בשלב הבא ניצור נתונים באמצעות מסגרת העומס של Python Locust.io כדי לדמות שחקנים שנרשמים למשחק ומשחקים. לאחר מכן צריך להריץ שאילתה על Spanner כדי לקבוע כמה שחקנים משחקים, ונתונים סטטיסטיים מסוימים על השחקנים במשחקי ניצחון לעומת משחקים שבהם שיחקו.

בשלב האחרון מנקים את המשאבים שנוצרו בשיעור ה-Lab הזה.

מה תפַתחו

במסגרת שיעור ה-Lab הזה:

  • יצירת מכונה של Spanner
  • פריסה של שירות פרופיל שכתוב ב'מעבר לטיפול בהרשמה לנגן'
  • פריסת שירות התאמה שנכתבו ב-Go כדי להקצות שחקנים למשחקים, לקבוע מנצחים ולעדכן את השחקנים הנתונים הסטטיסטיים של המשחק.

מה תלמדו

  • איך מגדירים מכונת Cloud Spanner
  • איך יוצרים סכימה ומסד נתונים של משחקים
  • איך לפרוס אפליקציות של Go כדי שיעבדו עם Cloud Spanner
  • איך יוצרים נתונים באמצעות Locust
  • איך מריצים שאילתות על נתונים ב-Cloud Spanner כדי לענות על שאלות לגבי משחקים ונגנים.

מה נדרש

  • פרויקט ב-Google Cloud שמחובר לחשבון לחיוב.
  • דפדפן אינטרנט כמו Chrome או Firefox.

2. הגדרה ודרישות

יצירת פרויקט

אם אין לכם עדיין חשבון Google (Gmail או Google Apps), עליכם ליצור חשבון. נכנסים למסוף Google Cloud Platform ( console.cloud.google.com) ויוצרים פרויקט חדש.

אם כבר יש לכם פרויקט, לוחצים על התפריט הנפתח לבחירת פרויקט בפינה השמאלית העליונה של המסוף:

6c9406d9b014760.png

ולוחצים על 'פרויקט חדש'. בתיבת הדו-שיח שמתקבלת כדי ליצור פרויקט חדש:

949d83c8a4ee17d9.png

אם עדיין אין לכם פרויקט, אמורה להופיע תיבת דו-שיח כזו כדי ליצור את הפרויקט הראשון:

870a3cbd6541ee86.png

בתיבת הדו-שיח הבאה ליצירת פרויקט תוכלו להזין את פרטי הפרויקט החדש:

6a92c57d3250a4b3.png

חשוב לזכור את מזהה הפרויקט, שהוא שם ייחודי בכל הפרויקטים ב-Google Cloud (השם שלמעלה כבר תפוס ולא מתאים לכם, סליחה). בהמשך ב-Codelab הזה, המערכת תתייחס אליה בתור PROJECT_ID.

בשלב הבא, אם עדיין לא עשית זאת, יהיה עליך להפעיל את החיוב ב-Developers Console כדי להשתמש במשאבים של Google Cloud ולהפעיל את Cloud Spanner API.

15d0ef27a8fbab27.png

ההרצה של Codelab הזה לא אמורה לעלות לך יותר מכמה דולרים, אבל זה יכול להיות גבוה יותר אם תחליטו להשתמש ביותר משאבים או אם תשאירו אותם פועלים (עיינו בקטע 'ניקוי' בסוף המסמך). התמחור ב-Google Cloud Spanner מופיע כאן.

משתמשים חדשים ב-Google Cloud Platform זכאים לתקופת ניסיון בחינם בשווי 300$, שמאפשרת ל-Codelab הזה בחינם לגמרי.

הגדרת Google Cloud Shell

אומנם אפשר להפעיל את Google Cloud ואת Spanner מרחוק מהמחשב הנייד, אבל ב-Codelab הזה נשתמש ב-Google Cloud Shell, סביבת שורת הפקודה שפועלת ב-Cloud.

המכונה הווירטואלית הזו שמבוססת על Debian נטענת עם כל הכלים למפתחים שדרושים לכם. יש בה ספריית בית בנפח מתמיד של 5GB והיא פועלת ב-Google Cloud, מה שמשפר משמעותית את ביצועי הרשת והאימות. כלומר, כל מה שדרוש ל-Codelab הזה הוא דפדפן (כן, הוא פועל ב-Chromebook).

  1. כדי להפעיל את Cloud Shell ממסוף Cloud, לוחצים על Activate Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (ההקצאה וההתחברות של הסביבה אמורות להימשך כמה דקות).

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

צילום מסך מתאריך 2017-06-14 בשעה 22: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:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell גם מגדירה משתני סביבה כברירת מחדל, והוא יכול להיות שימושי כשמריצים פקודות עתידיות.

echo $GOOGLE_CLOUD_PROJECT

פלט הפקודה

<PROJECT_ID>

להורדת הקוד

אפשר להוריד את הקוד של שיעור ה-Lab הזה ב-Cloud Shell. ערכות ה-SDK מבוססות על גרסת 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 הוא framework לבדיקת עומסים ב-Python שבעזרתו אפשר לבדוק נקודות קצה ל-API ל-REST. ב-Codelab הזה, יש לנו 2 בדיקות עומס שונות ב'גנרטורים' שנדגיש:

  • 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

סיכום

בשלב הזה הגדרתם את הפרויקט אם עדיין לא השתמשתם בו, הפעלתם את המעטפת בענן והורדתם את הקוד לשיעור ה-Lab הזה.

בשלב האחרון מגדירים את Locust ליצירת טעינה בשלב מאוחר יותר בשיעור ה-Lab.

השלב הבא

בשלב הבא מגדירים את המכונה ואת מסד הנתונים של Cloud Spanner.

3. יצירת מכונה ומסד נתונים של Spanner

יצירת מכונת Spanner

בשלב הזה מגדירים מכונת Spanner ל-Codelab. מחפשים את הערך Spanner 1a6580bd3d3e6783.png בתפריט ההמבורגר השמאלי 3129589f7bc9e5ce.png או מחפשים את Spanner על ידי הקשה על "/". ומקלידים 'Spanner'

36e52f8df8e13b99.png

אחר כך לוחצים על 95269e75bc8c3e4d.png וממלאים את הטופס: מזינים את שם המכונה cloudspanner-gaming למכונה, בוחרים הגדרה (צריך לבחור מופע אזורי כמו us-central1) ומגדירים את מספר הצמתים. בשביל ה-Codelab הזה נזדקק רק ל-500 processing units.

לסיום, לחצו על 'יצירה' ותוך שניות יש לכם מכונה של Cloud Spanner.

4457c324c94f93e6.png

יצירת מסד הנתונים והסכימה

כשהמכונה פועלת, אפשר ליצור את מסד הנתונים. Spanner מאפשר להשתמש בכמה מסדי נתונים במכונה אחת.

מסד הנתונים הוא המקום שבו מגדירים את הסכימה. תוכלו גם לקבוע למי תהיה גישה למסד הנתונים, להגדיר הצפנה מותאמת אישית, להגדיר את כלי האופטימיזציה ולהגדיר את תקופת השמירה.

במכונות במספר אזורים, אפשר גם להגדיר את חשבון ה-Lead כברירת המחדל. מידע נוסף על מסדי נתונים ב-Spanner

ב-Code-Lab הזה תיצרו את מסד הנתונים עם אפשרויות ברירת מחדל, ותספקו את הסכימה בזמן היצירה.

בשיעור ה-Lab הזה ייווצרו שתי טבלאות: שחקנים ומשחקים.

77651ac12e47fe2a.png

שחקנים יכולים להשתתף במשחקים רבים לאורך זמן, אבל רק משחק אחד בכל פעם. השחקנים משתמשים גם בנתונים סטטיסטיים בתור סוג נתונים בפורמט JSON כדי לעקוב אחר נתונים סטטיסטיים מעניינים כמו games_played ו-games_won. בגלל שיכול להיות שיתווספו נתונים סטטיסטיים אחרים בהמשך, זו למעשה עמודה ללא סכימה שמיועדת לנגנים.

משחקים עוקבים אחרי השחקנים שהשתתפו בהם באמצעות סוג הנתונים ARRAY של Spanner. המאפיינים המנצחים והמאפיינים שהסתיימו במשחק לא מאוכלסים עד לסגירת המשחק.

יש מפתח זר אחד כדי לוודא שהמשחק current_game של השחקן תקף.

כעת כדי ליצור את מסד הנתונים, לוחצים על 'Create Database' (יצירת מסד נתונים). בסקירה הכללית של המכונה:

a820db6c4a4d6f2d.png

ממלאים את הפרטים. האפשרויות החשובות הן השם והניב של מסד הנתונים. בדוגמה הזו קראנו למסד הנתונים משחק לדוגמה ובחרנו את הדיאלקט של SQL סטנדרטי של Google.

מעתיקים את ה-DDL הזה לסכימה ומדביקים אותו בתיבה:

CREATE TABLE games (
  gameUUID STRING(36) NOT NULL,
  players ARRAY<STRING(36)> NOT NULL,
  winner STRING(36),
  created TIMESTAMP,
  finished TIMESTAMP,
) PRIMARY KEY(gameUUID);

CREATE TABLE players (
  playerUUID STRING(36) NOT NULL,
  player_name STRING(64) NOT NULL,
  email STRING(MAX) NOT NULL,
  password_hash BYTES(60) NOT NULL,
  created TIMESTAMP,
  updated TIMESTAMP,
  stats JSON,
  account_balance NUMERIC NOT NULL DEFAULT (0.00),
  is_logged_in BOOL,
  last_login TIMESTAMP,
  valid_email BOOL,
  current_game STRING(36),
  FOREIGN KEY (current_game) REFERENCES games (gameUUID),
) PRIMARY KEY(playerUUID);

CREATE UNIQUE INDEX PlayerAuthentication ON players(email) STORING (password_hash);

CREATE INDEX PlayerGame ON players(current_game);

CREATE UNIQUE INDEX PlayerName ON players(player_name);

לאחר מכן, לוחצים על לחצן היצירה ומחכים כמה שניות עד שמסד הנתונים ייווצר.

הדף של יצירת מסד נתונים צריך להיראות כך:

d39d358dc7d32939.png

עכשיו תצטרכו להגדיר משתני סביבה ב-Cloud Shell כדי להשתמש בהם בהמשך שיעור ה-Lab בקוד. לכן שימו לב למזהה המכונה, והגדרתם את ה-INSTANCE_ID ואת ה-DATABASE_ID ב-Cloud Shell

f6f98848d3aea9c.png

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

סיכום

בשלב הזה יצרתם מופע של Spanner ואת מסד הנתונים sample-game. הגדרת גם את הסכימה שבה המשחק לדוגמה משתמש.

השלב הבא

בשלב הבא, פורסים את שירות הפרופיל כדי לאפשר לשחקנים להירשם כדי לשחק במשחק!

4. פריסה של שירות הפרופיל

סקירה כללית של השירות

שירות הפרופיל הוא API ל-REST שכתוב ב-Go, והמערכת משתמשת ב-framework של ג'ין.

4fce45ee6c858b3e.png

ב-API הזה, השחקנים יכולים להירשם כדי לשחק במשחקים. נוצר באמצעות פקודת POST פשוטה שמקבלת שם של נגן, כתובת אימייל וסיסמה. הסיסמה מוצפנת באמצעות bcrypt והגיבוב מאוחסן במסד הנתונים.

אימייל נחשב כמזהה ייחודי, ואילו player_name משמש למטרות תצוגה של המשחק.

ה-API הזה לא מטפל כרגע בהתחברות, אבל ייתכן שתוכלו לבצע את ההטמעה הזו כתרגול נוסף.

הקובץ ./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 ו-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 api כדי ליצור שחקן חדש.

השלבים הבאים

בשלב הבא, תפרסו את שירות ההתאמה להתאמות.

5. פריסת שירות חיפוש ההתאמות

סקירה כללית של השירות

שירות ההתאמה הוא API ל-REST שכתוב ב-Go, והמערכת משתמשת במסגרת של ג'ין.

9aecd571df0dcd7c.png

ב-API הזה, משחקים נוצרים ונסגרים. כשנוצר משחק, מוקצים לו 10 שחקנים שאינם משחקים בו באותו רגע.

כשמשחק נסגר, נבחר מנצח אקראי והמשחק של כל שחקן הנתונים הסטטיסטיים של games_played ושל games_won מותאמים. בנוסף, כל שחקן מתעדכן כדי לציין שהוא כבר לא משחק ולכן הוא זמין לשחק במשחקים עתידיים.

ההגדרות והקוד של קובץ ./src/golang/matchmaking-service/main.go של שירות השידוך דומים להגדרות ולקוד של שירות הפרופיל, ולכן אין צורך לחזור עליו כאן. השירות הזה חושף שתי נקודות קצה (endpoints) ראשיות באופן הבא:

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:

90eceac76a6b90b.png

לאחר מכן, מריצים את פקודת ה-curl הבאה:

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

פלט הפקודה:

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

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

סגירת המשחק

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

פלט הפקודה:

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

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

סיכום

בשלב הזה פרסתם את שירות שידוך כדי לטפל ביצירת משחקים ובהקצאת השחקנים למשחק הזה. השירות הזה גם מטפל בסגירת משחק, שבוחר מנצח אקראי ומעדכנת את כל השחקנים במשחקים הנתונים הסטטיסטיים של games_played ו-games_won.

השלבים הבאים

עכשיו, כשהשירותים שלך פועלים, זה הזמן לעודד שחקנים להירשם ולשחק במשחקים!

6. הפעלה

עכשיו, כשהפרופיל ושירותי השידוך פועלים, אפשר ליצור עומס באמצעות מחוללי הלוך ושוב שזמינים.

Locust מציע ממשק אינטרנט להפעלת מחוללים, אבל בשיעור ה-Lab הזה תשתמשו בשורת הפקודה (–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

סיכום

בשלב הזה, הדמייתם שחקנים שנרשמים למשחקים, ולאחר מכן הרצתם סימולציות כדי לשחק במשחקים באמצעות שירות השידוכים. בהדמיות האלה נעשה שימוש ב-framework של Locust Python כדי לשלוח בקשות לשירותים שלנו API בארכיטקטורת REST

אפשר לשנות את משך הזמן שבו יוצרים שחקנים ומשחקים במשחקים, וגם את מספר המשתמשים בו-זמנית (-u).

השלבים הבאים

לאחר הסימולציה, מומלץ לבדוק נתונים סטטיסטיים שונים על ידי שליחת שאילתות ל-Spanner.

7. אחזור נתונים סטטיסטיים של משחקים

עכשיו, לאחר שהשחקנים שלנו יכולים להירשם ולשחק במשחקים, כדאי לבדוק את הנתונים הסטטיסטיים.

לשם כך, צריך להשתמש במסוף Cloud כדי לשלוח בקשות לשאילתות ל-Spanner.

b5e3154c6f7cb0cf.png

בדיקת משחקים פתוחים לעומת משחקים סגורים

משחק סגור הוא משחק שחותמת הזמן שלו הסתיימה ומשחק פתוח יסיים להיות NULL. הערך הזה מוגדר כשהמשחק נסגר.

לכן, בשאילתה הזו תוכלו לבדוק כמה משחקים פתוחים וכמה משחקים סגורים:

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

תוצאה:

Type

NumGames

Open Games

0

Closed Games

175

בדיקת מספר השחקנים שמשחקים לעומת לא משחקים

שחקן משחק במשחק אם מוגדרת העמודה current_game שלו. אחרת, הם לא משחקים כרגע במשחק.

כדי להשוות כמה שחקנים פועלים כרגע ולא פועלים, אפשר להשתמש בשאילתה הבאה:

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

תוצאה:

Type

NumPlayers

Playing

0

Not Playing

310

קביעה של המנצחים המובילים

כשמשחק נסגר, אחד מהשחקנים נבחר באופן אקראי להיות המנצח. הנתונים הסטטיסטיים של השחקן הזה (games_won) מצטברים במהלך סגירת המשחק.

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

תוצאה:

playerUUID

נתונים סטטיסטיים

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

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

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

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

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

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

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

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

15513852-3f2a-494f-b437-fe7125d15f1b

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

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

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

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

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

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

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

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

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

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

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

סיכום

בשלב הזה בדקתם נתונים סטטיסטיים שונים של השחקנים והמשחקים על ידי שימוש במסוף Cloud לשליחת שאילתות ב-Spanner.

השלבים הבאים

עכשיו הגיע הזמן לפנות מקום.

8. הסרת המשאבים (אופציונלי)

כדי להסיר את המשאבים, נכנסים לקטע של Cloud Spanner במסוף Cloud ומוחקים את המכונה 'cloudspanner-gaming' שיצרנו בשלב ה-Codelab בשם "Setup a Cloud Spanner Instance".

9. מעולה!

כל הכבוד, פרסת בהצלחה משחק לדוגמה ב-Spanner

מה השלב הבא?

בשיעור ה-Lab הזה למדתם על נושאים שונים לעבודה עם Spanner באמצעות מנהל התקן golang. הוא נועד לספק לכם בסיס טוב יותר להבנת מושגים חשובים, כמו:

  • עיצוב סכימה
  • DML לעומת מוטציות
  • עבודה עם Golang

כדאי לעיין ב-codelab ב-Cloud Spanner Game Trading Post כדי לראות דוגמה נוספת לעבודה עם Spanner כקצה עורפי במשחק!