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

1. מבוא

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

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

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

413fdd57bb0b68bc.png

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

לבסוף, תסירו את המשאבים שנוצרו בשיעור ה-Lab הזה.

מה תפַתחו

במסגרת ה-Lab הזה:

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

מה תלמדו

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

הדרישות

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

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

יצירת פרויקט

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

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

6c9406d9b014760.png

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

949d83c8a4ee17d9.png

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

870a3cbd6541ee86.png

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

6a92c57d3250a4b3.png

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

לאחר מכן, אם עדיין לא עשיתם זאת, תצטרכו להפעיל את החיוב במסוף למפתחים כדי להשתמש במשאבים של 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, סביבת שורת פקודה שפועלת בענן.

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

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

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

Screen Shot 2017-06-14 at 10.13.43 PM.png

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

gcloud auth list

פלט הפקודה

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

פלט הפקודה

[core]
project = <PROJECT_ID>

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

gcloud config set project <PROJECT_ID>

מחפשים את PROJECT_ID? כדאי לבדוק באיזה מזהה השתמשתם בשלבי ההגדרה, או לחפש אותו בלוח הבקרה של Cloud Console:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

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

echo $GOOGLE_CLOUD_PROJECT

פלט הפקודה

<PROJECT_ID>

הורדת הקוד

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

  • authentication_server.py: מכיל משימות ליצירת שחקנים ולאחזור שחקן אקראי כדי לחקות חיפושים של נקודה יחידה.
  • match_server.py: מכיל משימות ליצירת משחקים ולסגירת משחקים. כשיוצרים משחקים, המערכת מקצה 100 שחקנים אקראיים שלא משחקים כרגע. סגירת משחקים תעדכן את הנתונים הסטטיסטיים של games_played ו-games_won, ותאפשר להקצות את השחקנים האלה למשחק עתידי.

כדי להפעיל את Locust ב-Cloud Shell, צריך Python בגרסה 3.7 ואילך. ב-Cloud Shell מותקנת גרסה Python 3.9, לכן כל מה שצריך לעשות הוא לאמת את הגרסה:

python -V

פלט הפקודה

Python 3.9.12

עכשיו אפשר להתקין את הדרישות של Locust.

pip3 install -r requirements.txt

פלט הפקודה

Collecting locust==2.11.1
*snip*
Successfully installed ConfigArgParse-1.5.3 Flask-BasicAuth-0.2.0 Flask-Cors-3.0.10 brotli-1.0.9 gevent-21.12.0 geventhttpclient-2.0.2 greenlet-1.1.3 locust-2.11.1 msgpack-1.0.4 psutil-5.9.2 pyzmq-22.3.0 roundrobin-0.0.4 zope.event-4.5.0 zope.interface-5.4.0

עכשיו מעדכנים את הנתיב (PATH) כדי שניתן יהיה למצוא את הקובץ הבינארי locust שהותקן לאחרונה:

PATH=~/.local/bin":$PATH"
which locust

פלט הפקודה

/home/<user>/.local/bin/locust

סיכום

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

לבסוף, תגדירו את Locust ליצירת עומס בהמשך המעבדה.

הבא בתור

בשלב הבא תגדירו את מסד הנתונים ואת מכונת 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.

לבסוף, לוחצים על Create (יצירה) ותוך שניות מופעלת לכם אינטס של Cloud Spanner.

4457c324c94f93e6.png

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

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

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

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

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

בשיעור ה-Lab הזה ייצרו שתי טבלאות: players ו-games.

77651ac12e47fe2a.png

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

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

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

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

a820db6c4a4d6f2d.png

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

בסכימה, מעתיקים ומדביקים את ה-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 כדי להשתמש בהם בהמשך בסדנת הקוד. לכן, כדאי לרשום את instance-id ולהגדיר את INSTANCE_ID ו-DATABASE_ID ב-Cloud Shell.

f6f98848d3aea9c.png

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

סיכום

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

הבא בתור

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

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

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

שירות הפרופילים הוא API בארכיטקטורת REST שנכתב ב-Go ומתבסס על מסגרת gin.

4fce45ee6c858b3e.png

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

המאפיין email נחשב למזהה ייחודי, ואילו המאפיין 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"

סיכום

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

השלבים הבאים

בשלב הבא תפעילו את שירות השידוכים.

5. פריסת שירות השידוכים

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

שירות השידוך הוא API בארכיטקטורת REST שנכתב ב-Go ומתבסס על gin framework.

9aecd571df0dcd7c.png

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

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

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

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

   router.Use(setSpannerConnection())

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

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

השירות הזה מספק מבנה Game, וגם מבנים מצומצמים של Player ו-PlayerStats:

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

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

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

כדי ליצור משחק, שירות חיפוש יריבים למשחק בוחר באופן אקראי 100 שחקנים שלא משחקים כרגע.

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

// Create a new game and assign players
// Players that are not currently playing a game are eligble to be selected for the new game
// Current implementation allows for less than numPlayers to be placed in a game
func (g *Game) CreateGame(ctx context.Context, client spanner.Client) error {
   // Initialize game values
   g.GameUUID = generateUUID()

   numPlayers := 10

   // Create and assign
   _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
       var m []*spanner.Mutation

       // get players
       query := fmt.Sprintf("SELECT playerUUID FROM (SELECT playerUUID FROM players WHERE current_game IS NULL LIMIT 10000) TABLESAMPLE RESERVOIR (%d ROWS)", numPlayers)
       stmt := spanner.Statement{SQL: query}
       iter := txn.Query(ctx, stmt)

       playerRows, err := readRows(iter)
       if err != nil {
           return err
       }

       var playerUUIDs []string

       for _, row := range playerRows {
           var pUUID string
           if err := row.Columns(&pUUID); err != nil {
               return err
           }

           playerUUIDs = append(playerUUIDs, pUUID)
       }

       // Create the game
       gCols := []string{"gameUUID", "players", "created"}
       m = append(m, spanner.Insert("games", gCols, []interface{}{g.GameUUID, playerUUIDs, time.Now()}))

       // Update players to lock into this game
       for _, p := range playerUUIDs {
           pCols := []string{"playerUUID", "current_game"}
           m = append(m, spanner.Update("players", pCols, []interface{}{p, g.GameUUID}))
       }

       txn.BufferWrite(m)

       return nil
   })

   if err != nil {
       return err
   }

   return nil
}

הבחירה האקראית של השחקנים מתבצעת באמצעות SQL, בעזרת היכולת TABLESPACE RESERVOIR של GoogleSQL.

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

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

func determineWinner(playerUUIDs []string) string {
   if len(playerUUIDs) == 0 {
       return ""
   }

   var winnerUUID string

   rand.Seed(time.Now().UnixNano())
   offset := rand.Intn(len(playerUUIDs))
   winnerUUID = playerUUIDs[offset]
   return winnerUUID
}

// Given a list of players and a winner's UUID, update players of a game
// Updating players involves closing out the game (current_game = NULL) and
// updating their game stats. Specifically, we are incrementing games_played.
// If the player is the determined winner, then their games_won stat is incremented.
func (g Game) updateGamePlayers(ctx context.Context, players []Player, txn *spanner.ReadWriteTransaction) error {
   for _, p := range players {
       // Modify stats
       var pStats PlayerStats
       json.Unmarshal([]byte(p.Stats.String()), &pStats)

       pStats.Games_played = pStats.Games_played + 1

       if p.PlayerUUID == g.Winner {
           pStats.Games_won = pStats.Games_won + 1
       }
       updatedStats, _ := json.Marshal(pStats)
       p.Stats.UnmarshalJSON(updatedStats)

       // Update player
       // If player's current game isn't the same as this game, that's an error
       if p.Current_game != g.GameUUID {
           errorMsg := fmt.Sprintf("Player '%s' doesn't belong to game '%s'.", p.PlayerUUID, g.GameUUID)
           return errors.New(errorMsg)
       }

       cols := []string{"playerUUID", "current_game", "stats"}
       newGame := spanner.NullString{
           StringVal: "",
           Valid:     false,
       }

       txn.BufferWrite([]*spanner.Mutation{
           spanner.Update("players", cols, []interface{}{p.PlayerUUID, newGame, p.Stats}),
       })
   }

   return nil
}

// Closing game. When provided a Game, choose a random winner and close out the game.
// A game is closed by setting the winner and finished time.
// Additionally all players' game stats are updated, and the current_game is set to null to allow
// them to be chosen for a new game.
func (g *Game) CloseGame(ctx context.Context, client spanner.Client) error {
   // Close game
   _, err := client.ReadWriteTransaction(ctx,
       func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
           // Get game players
           playerUUIDs, players, err := g.getGamePlayers(ctx, txn)

           if err != nil {
               return err
           }

           // Might be an issue if there are no players!
           if len(playerUUIDs) == 0 {
               errorMsg := fmt.Sprintf("No players found for game '%s'", g.GameUUID)
               return errors.New(errorMsg)
           }

           // Get random winner
           g.Winner = determineWinner(playerUUIDs)

           // Validate game finished time is null
           row, err := txn.ReadRow(ctx, "games", spanner.Key{g.GameUUID}, []string{"finished"})
           if err != nil {
               return err
           }

           if err := row.Column(0, &g.Finished); err != nil {
               return err
           }

           // If time is not null, then the game is already marked as finished. 
           // That's an error.
           if !g.Finished.IsNull() {
               errorMsg := fmt.Sprintf("Game '%s' is already finished.", g.GameUUID)
               return errors.New(errorMsg)
           }

           cols := []string{"gameUUID", "finished", "winner"}
           txn.BufferWrite([]*spanner.Mutation{
               spanner.Update("games", cols, []interface{}{g.GameUUID, time.Now(), g.Winner}),
           })

           // Update each player to increment stats.games_played 
           // (and stats.games_won if winner), and set current_game 
           // to null so they can be chosen for a new game
           playerErr := g.updateGamePlayers(ctx, players, txn)
           if playerErr != nil {
               return playerErr
           }

           return nil
       })

   if err != nil {
       return err
   }

   return nil
}

ההגדרה מתבצעת שוב באמצעות משתני סביבה, כמו שמתואר בקובץ ./src/golang/matchmaking-service/config/config.go של השירות.

   // Server defaults
   viper.SetDefault("server.host", "localhost")
   viper.SetDefault("server.port", 8081)

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

כדי למנוע התנגשויות עם שירות הפרופיל, שירות זה פועל בכתובת localhost:8081 כברירת מחדל.

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

הפעלת שירות השידוכים

מריצים את השירות באמצעות הפקודה go. הפעולה הזו תגדיר את השירות שפועל ביציאה 8082. לשירות הזה יש הרבה יחסי תלות כמו לשירות הפרופיל, לכן לא יורדו יחסי תלות חדשים.

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

פלט הפקודה:

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

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

[GIN-debug] POST   /games/create             --> main.createGame (4 handlers)
[GIN-debug] PUT    /games/close              --> main.closeGame (4 handlers)
[GIN-debug] Listening and serving HTTP on localhost:8081

יצירת משחק

בודקים את השירות כדי ליצור משחק. קודם פותחים טרמינל חדש ב-Cloud Shell:

90eceac76a6bb90b.png

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

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

פלט הפקודה:

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

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

סגירת המשחק

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

פלט הפקודה:

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

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

סיכום

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

השלבים הבאים

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

6. מתחילים לשחק

עכשיו, אחרי שהפרופיל ושירותי חיפוש יריבים למשחק פועלים, אפשר ליצור עומס באמצעות מחוללי locust שסופקו.

ל-Locust יש ממשק אינטרנט להרצת הגנרטורים, אבל בשיעור ה-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

סיכום

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

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

השלבים הבאים

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

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

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

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

b5e3154c6f7cb0cf.png

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

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

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

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

תוצאה:

Type

NumGames

Open Games

0

Closed Games

175

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

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

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

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

תוצאה:

Type

NumPlayers

Playing

0

Not Playing

310

קביעת הזוכים המובילים

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

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

תוצאה:

playerUUID

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

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

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

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

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

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

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

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

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

15513852-3f2a-494f-b437-fe7125d15f1b

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

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

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

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

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

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

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

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

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

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

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

סיכום

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

השלבים הבאים

עכשיו הגיע הזמן לנקות!

8. ניקוי (אופציונלי)

כדי לנקות, פשוט נכנסים אל הקטע Cloud Spanner ב-Cloud Console ומוחקים את המופע cloudspanner-gaming שיצרנו בשלב של ה-codelab שנקרא 'הגדרת מופע של Cloud Spanner'.

9. מעולה!

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

מה השלב הבא?

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

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

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