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

בשלב הבא תיצרו נתונים באמצעות מסגרת הטעינה של Python Locust.io כדי לדמות שחקנים שנרשמים ומשחקים במשחק. לאחר מכן, תריצו שאילתה ב-Spanner כדי לקבוע כמה שחקנים משחקים, ולקבל נתונים סטטיסטיים על המשחקים שהשחקנים ניצחו לעומת המשחקים שהם שיחקו.
לבסוף, תסירו את המשאבים שנוצרו בשיעור ה-Lab הזה.
מה תפַתחו
במסגרת ה-Lab הזה:
- יצירת מופע Spanner
- פריסת שירות פרופילים שנכתב ב-Go כדי לטפל בהרשמה של שחקנים
- פריסת שירות חיפוש יריבים למשחק שנכתב ב-Go כדי להקצות שחקנים למשחקים, לקבוע את המנצחים ולעדכן את הנתונים הסטטיסטיים של השחקנים במשחק.
מה תלמדו
- איך מגדירים מופע של Cloud Spanner
- איך יוצרים מסד נתונים וסכימה של משחק
- איך פורסים אפליקציות Go לעבודה עם Cloud Spanner
- איך יוצרים נתונים באמצעות Locust
- איך שולחים שאילתות לנתונים ב-Cloud Spanner כדי לקבל תשובות לשאלות לגבי משחקים ושחקנים.
הדרישות
2. הגדרה ודרישות
יצירת פרויקט
אם עדיין אין לכם חשבון Google (Gmail או Google Apps), אתם צריכים ליצור חשבון. נכנסים אל Google Cloud Platform Console ( console.cloud.google.com) ויוצרים פרויקט חדש.
אם כבר יש לכם פרויקט, לוחצים על התפריט הנפתח לבחירת פרויקט בפינה הימנית העליונה של המסוף:

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

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

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

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

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


אחרי שמתחברים ל-Cloud Shell, אמור להופיע אימות שכבר בוצע ושהפרויקט כבר הוגדר לפי PROJECT_ID.
gcloud auth list
פלט הפקודה
Credentialed accounts:
- <myaccount>@<mydomain>.com (active)
gcloud config list project
פלט הפקודה
[core]
project = <PROJECT_ID>
אם מסיבה כלשהי הפרויקט לא מוגדר, פשוט מריצים את הפקודה הבאה:
gcloud config set project <PROJECT_ID>
מחפשים את PROJECT_ID? כדאי לבדוק באיזה מזהה השתמשתם בשלבי ההגדרה, או לחפש אותו בלוח הבקרה של Cloud Console:

ב-Cloud Shell מוגדרים גם כמה משתני סביבה כברירת מחדל, שיכולים להיות שימושיים כשמריצים פקודות בעתיד.
echo $GOOGLE_CLOUD_PROJECT
פלט הפקודה
<PROJECT_ID>
הורדת הקוד
ב-Cloud Shell, אפשר להוריד את הקוד של שיעור ה-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
בתפריט ההמבורגר בפינה הימנית העליונה
או מחפשים את Spanner על ידי הקשה על '/' והקלדת 'Spanner'.

לאחר מכן לוחצים על
וממלאים את הטופס. מזינים את שם המכונה cloudspanner-gaming של המכונה, בוחרים הגדרה (בוחרים מכונה אזורית כמו us-central1) ומגדירים את מספר הצמתים. ב-codelab הזה נשתמש רק ב-500 processing units.
לבסוף, לוחצים על Create (יצירה) ותוך שניות מופעלת לכם אינטס של Cloud Spanner.

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

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

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

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

export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
סיכום
בשלב הזה יצרתם מופע Spanner ומסד נתונים sample-game. הגדרתם גם את הסכימה שמשמשת את המשחק לדוגמה הזה.
הבא בתור
בשלב הבא, תפרסו את שירות הפרופיל כדי לאפשר לשחקנים להירשם למשחק.
4. פריסת שירות הפרופילים
סקירה כללית על השירות
שירות הפרופילים הוא API בארכיטקטורת REST שנכתב ב-Go ומתבסס על מסגרת gin.

ב-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.

ב-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:

לאחר מכן, מריצים את פקודת ה-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.

בדיקה של משחקים פתוחים לעומת משחקים סגורים
משחק סגור הוא משחק שחותמת הזמן finished שלו מלאה, ואילו משחק פתוח הוא משחק שחותמת הזמן finished שלו היא NULL. הערך הזה מוגדר כשהמשחק נסגר.
לכן, השאילתה הזו תאפשר לכם לבדוק כמה משחקים פתוחים וכמה סגורים:
SELECT Type, NumGames FROM
(SELECT "Open Games" as Type, count(*) as NumGames FROM games WHERE finished IS NULL
UNION ALL
SELECT "Closed Games" as Type, count(*) as NumGames FROM games WHERE finished IS NOT NULL
)
תוצאה:
|
|
|
|
|
|
בדיקת מספר השחקנים שמשחקים לעומת מספר השחקנים שלא משחקים
שחקן משחק במשחק אם העמודה current_game שלו מוגדרת. אחרת, הם לא משחקים כרגע.
כדי להשוות בין מספר השחקנים שמשחקים כרגע לבין מספר השחקנים שלא משחקים כרגע, משתמשים בשאילתה הבאה:
SELECT Type, NumPlayers FROM
(SELECT "Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NOT NULL
UNION ALL
SELECT "Not Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NULL
)
תוצאה:
|
|
|
|
|
|
קביעת הזוכים המובילים
כשמשחק נסגר, אחד מהשחקנים נבחר באופן אקראי להיות המנצח. הסטטיסטיקה של השחקן games_won גדלה במהלך סגירת המשחק.
SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;
תוצאה:
playerUUID | נתונים סטטיסטיים |
07e247c5-f88e-4bca-a7bc-12d2485f2f2b | {"games_played":49,"games_won":1} |
09b72595-40af-4406-a000-2fb56c58fe92 | {"games_played":56,"games_won":1} |
1002385b-02a0-462b-a8e7-05c9b27223aa | {"games_played":66,"games_won":1} |
13ec3770-7ae3-495f-9b53-6322d8e8d6c3 | {"games_played":44,"games_won":1} |
15513852-3f2a-494f-b437-fe7125d15f1b | {"games_played":49,"games_won":1} |
17faec64-4f77-475c-8df8-6ab026cf6698 | {"games_played":50,"games_won":1} |
1abfcb27-037d-446d-bb7a-b5cd17b5733d | {"games_played":63,"games_won":1} |
2109a33e-88bd-4e74-a35c-a7914d9e3bde | {"games_played":56,"games_won":2} |
222e37d9-06b0-4674-865d-a0e5fb80121e | {"games_played":60,"games_won":1} |
22ced15c-0da6-4fd9-8cb2-1ffd233b3c56 | {"games_played":50,"games_won":1} |
סיכום
בשלב הזה, בדקתם נתונים סטטיסטיים שונים של שחקנים ומשחקים באמצעות שאילתות ב-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 כקצה עורפי למשחק.