1. מבוא
Cloud Spanner הוא שירות מנוהל של מסד נתונים רלציוני בקנה מידה אופקי שאפשר להתאים לעומס, שזמין בכל העולם. השירות מספק טרנזקציות ACID וסמנטיקה של SQL, בלי לוותר על ביצועים וזמינות גבוהה.
בזכות התכונות האלה, Spanner מתאים מאוד לארכיטקטורה של משחקים שרוצים להפעיל בהם בסיס שחקנים גלובלי או שיש להם חששות לגבי עקביות הנתונים.
בשיעור ה-Lab הזה תיצרו שני שירותי Go שיוצרים אינטראקציה עם מסד נתונים אזורי של Spanner כדי לאפשר לשחקנים להירשם ולהתחיל לשחק.
בשלב הבא ניצור נתונים באמצעות מסגרת העומס של Python Locust.io כדי לדמות שחקנים שנרשמים למשחק ומשחקים. לאחר מכן צריך להריץ שאילתה על Spanner כדי לקבוע כמה שחקנים משחקים, ונתונים סטטיסטיים מסוימים על השחקנים במשחקי ניצחון לעומת משחקים שבהם שיחקו.
בשלב האחרון מנקים את המשאבים שנוצרו בשיעור ה-Lab הזה.
מה תפַתחו
במסגרת שיעור ה-Lab הזה:
- יצירת מכונה של Spanner
- פריסה של שירות פרופיל שכתוב ב'מעבר לטיפול בהרשמה לנגן'
- פריסת שירות התאמה שנכתבו ב-Go כדי להקצות שחקנים למשחקים, לקבוע מנצחים ולעדכן את השחקנים הנתונים הסטטיסטיים של המשחק.
מה תלמדו
- איך מגדירים מכונת Cloud Spanner
- איך יוצרים סכימה ומסד נתונים של משחקים
- איך לפרוס אפליקציות של Go כדי שיעבדו עם Cloud Spanner
- איך יוצרים נתונים באמצעות Locust
- איך מריצים שאילתות על נתונים ב-Cloud Spanner כדי לענות על שאלות לגבי משחקים ונגנים.
מה נדרש
2. הגדרה ודרישות
יצירת פרויקט
אם אין לכם עדיין חשבון Google (Gmail או Google Apps), עליכם ליצור חשבון. נכנסים למסוף Google Cloud Platform ( console.cloud.google.com) ויוצרים פרויקט חדש.
אם כבר יש לכם פרויקט, לוחצים על התפריט הנפתח לבחירת פרויקט בפינה השמאלית העליונה של המסוף:
ולוחצים על 'פרויקט חדש'. בתיבת הדו-שיח שמתקבלת כדי ליצור פרויקט חדש:
אם עדיין אין לכם פרויקט, אמורה להופיע תיבת דו-שיח כזו כדי ליצור את הפרויקט הראשון:
בתיבת הדו-שיח הבאה ליצירת פרויקט תוכלו להזין את פרטי הפרויקט החדש:
חשוב לזכור את מזהה הפרויקט, שהוא שם ייחודי בכל הפרויקטים ב-Google Cloud (השם שלמעלה כבר תפוס ולא מתאים לכם, סליחה). בהמשך ב-Codelab הזה, המערכת תתייחס אליה בתור PROJECT_ID.
בשלב הבא, אם עדיין לא עשית זאת, יהיה עליך להפעיל את החיוב ב-Developers Console כדי להשתמש במשאבים של Google Cloud ולהפעיל את Cloud Spanner API.
ההרצה של 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).
- כדי להפעיל את Cloud Shell ממסוף Cloud, לוחצים על Activate 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:
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 בתפריט ההמבורגר השמאלי או מחפשים את Spanner על ידי הקשה על "/". ומקלידים 'Spanner'
אחר כך לוחצים על וממלאים את הטופס: מזינים את שם המכונה cloudspanner-gaming
למכונה, בוחרים הגדרה (צריך לבחור מופע אזורי כמו us-central1
) ומגדירים את מספר הצמתים. בשביל ה-Codelab הזה נזדקק רק ל-500 processing units
.
לסיום, לחצו על 'יצירה' ותוך שניות יש לכם מכונה של Cloud Spanner.
יצירת מסד הנתונים והסכימה
כשהמכונה פועלת, אפשר ליצור את מסד הנתונים. Spanner מאפשר להשתמש בכמה מסדי נתונים במכונה אחת.
מסד הנתונים הוא המקום שבו מגדירים את הסכימה. תוכלו גם לקבוע למי תהיה גישה למסד הנתונים, להגדיר הצפנה מותאמת אישית, להגדיר את כלי האופטימיזציה ולהגדיר את תקופת השמירה.
במכונות במספר אזורים, אפשר גם להגדיר את חשבון ה-Lead כברירת המחדל. מידע נוסף על מסדי נתונים ב-Spanner
ב-Code-Lab הזה תיצרו את מסד הנתונים עם אפשרויות ברירת מחדל, ותספקו את הסכימה בזמן היצירה.
בשיעור ה-Lab הזה ייווצרו שתי טבלאות: שחקנים ומשחקים.
שחקנים יכולים להשתתף במשחקים רבים לאורך זמן, אבל רק משחק אחד בכל פעם. השחקנים משתמשים גם בנתונים סטטיסטיים בתור סוג נתונים בפורמט JSON כדי לעקוב אחר נתונים סטטיסטיים מעניינים כמו games_played ו-games_won. בגלל שיכול להיות שיתווספו נתונים סטטיסטיים אחרים בהמשך, זו למעשה עמודה ללא סכימה שמיועדת לנגנים.
משחקים עוקבים אחרי השחקנים שהשתתפו בהם באמצעות סוג הנתונים ARRAY של Spanner. המאפיינים המנצחים והמאפיינים שהסתיימו במשחק לא מאוכלסים עד לסגירת המשחק.
יש מפתח זר אחד כדי לוודא שהמשחק current_game של השחקן תקף.
כעת כדי ליצור את מסד הנתונים, לוחצים על 'Create Database' (יצירת מסד נתונים). בסקירה הכללית של המכונה:
ממלאים את הפרטים. האפשרויות החשובות הן השם והניב של מסד הנתונים. בדוגמה הזו קראנו למסד הנתונים משחק לדוגמה ובחרנו את הדיאלקט של 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);
לאחר מכן, לוחצים על לחצן היצירה ומחכים כמה שניות עד שמסד הנתונים ייווצר.
הדף של יצירת מסד נתונים צריך להיראות כך:
עכשיו תצטרכו להגדיר משתני סביבה ב-Cloud Shell כדי להשתמש בהם בהמשך שיעור ה-Lab בקוד. לכן שימו לב למזהה המכונה, והגדרתם את ה-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, והמערכת משתמשת ב-framework של ג'ין.
ב-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, והמערכת משתמשת במסגרת של ג'ין.
ב-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:
לאחר מכן, מריצים את פקודת ה-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.
בדיקת משחקים פתוחים לעומת משחקים סגורים
משחק סגור הוא משחק שחותמת הזמן שלו הסתיימה ומשחק פתוח יסיים להיות NULL. הערך הזה מוגדר כשהמשחק נסגר.
לכן, בשאילתה הזו תוכלו לבדוק כמה משחקים פתוחים וכמה משחקים סגורים:
SELECT Type, NumGames FROM
(SELECT "Open Games" as Type, count(*) as NumGames FROM games WHERE finished IS NULL
UNION ALL
SELECT "Closed Games" as Type, count(*) as NumGames FROM games WHERE finished IS NOT NULL
)
תוצאה:
|
|
|
|
|
|
בדיקת מספר השחקנים שמשחקים לעומת לא משחקים
שחקן משחק במשחק אם מוגדרת העמודה current_game שלו. אחרת, הם לא משחקים כרגע במשחק.
כדי להשוות כמה שחקנים פועלים כרגע ולא פועלים, אפשר להשתמש בשאילתה הבאה:
SELECT Type, NumPlayers FROM
(SELECT "Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NOT NULL
UNION ALL
SELECT "Not Playing" as Type, count(*) as NumPlayers FROM players WHERE current_game IS NULL
)
תוצאה:
|
|
|
|
|
|
קביעה של המנצחים המובילים
כשמשחק נסגר, אחד מהשחקנים נבחר באופן אקראי להיות המנצח. הנתונים הסטטיסטיים של השחקן הזה (games_won) מצטברים במהלך סגירת המשחק.
SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;
תוצאה:
playerUUID | נתונים סטטיסטיים |
07e247c5-f88e-4bca-a7bc-12d2485f2f2b | {"games_played":49,"games_won":1} |
09b72595-40af-4406-a000-2fb56c58fe92 | {"games_played":56,"games_won":1} |
1002385b-02a0-462b-a8e7-05c9b27223aa | {"games_played":66,"games_won":1} |
13ec3770-7ae3-495f-9b53-6322d8e8d6c3 | {"games_played":44,"games_won":1} |
15513852-3f2a-494f-b437-fe7125d15f1b | {"games_played":49,"games_won":1} |
17faec64-4f77-475c-8df8-6ab026cf6698 | {"games_played":50,"games_won":1} |
1abfcb27-037d-446d-bb7a-b5cd17b5733d | {"games_played":63,"games_won":1} |
2109a33e-88bd-4e74-a35c-a7914d9e3bde | {"games_played":56,"games_won":2} |
222e37d9-06b0-4674-865d-a0e5fb80121e | {"games_played":60,"games_won":1} |
22ced15c-0da6-4fd9-8cb2-1ffd233b3c56 | {"games_played":50,"games_won":1} |
סיכום
בשלב הזה בדקתם נתונים סטטיסטיים שונים של השחקנים והמשחקים על ידי שימוש במסוף Cloud לשליחת שאילתות ב-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 כקצה עורפי במשחק!