เกี่ยวกับ Codelab นี้
1 บทนำ
Cloud Spanner เป็นบริการฐานข้อมูลเชิงสัมพันธ์ที่รองรับการปรับขนาดในวงกว้างและกระจายไปทั่วโลกที่มีการจัดการครบวงจร ซึ่งมอบธุรกรรม ACID และความหมายของ SQL โดยไม่สูญเสียประสิทธิภาพการทำงานและความพร้อมใช้งานสูง
ฟีเจอร์เหล่านี้ทำให้ Spanner มีความเหมาะสมกับสถาปัตยกรรมของเกมที่ต้องการสร้างฐานผู้เล่นทั่วโลก หรือมีความกังวลเกี่ยวกับความสอดคล้องของข้อมูล
ในห้องทดลองนี้ คุณจะสร้างบริการ Go 2 บริการที่โต้ตอบกับฐานข้อมูล Spanner ในระดับภูมิภาคเพื่อให้ผู้เล่นลงชื่อสมัครใช้และเริ่มเล่นได้
ถัดไป คุณจะต้องสร้างข้อมูลโดยใช้ประโยชน์จากเฟรมเวิร์กการโหลด Python Locust.io เพื่อจำลองให้ผู้เล่นลงชื่อสมัครใช้และเล่นเกม จากนั้นค้นหา Spanner เพื่อระบุจำนวนผู้เล่นที่เล่น และสถิติบางอย่างเกี่ยวกับผู้เล่น เกมที่ชนะเทียบกับเกมที่เล่น
ขั้นตอนสุดท้าย คุณจะล้างทรัพยากรที่สร้างขึ้นในห้องทดลองนี้
สิ่งที่คุณจะสร้าง
ในห้องทดลองนี้ คุณจะทำสิ่งต่อไปนี้ได้
- สร้างอินสแตนซ์ Spanner
- ทำให้บริการโปรไฟล์ที่เขียนใน Go ใช้งานได้เพื่อจัดการการลงชื่อสมัครใช้โปรแกรมเล่น
- ใช้บริการจับคู่ที่เขียนใน 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 นี้น่าจะมีค่าใช้จ่ายไม่เกิน 2-3 ดอลลาร์ แต่อาจมากกว่านี้หากคุณตัดสินใจใช้ทรัพยากรเพิ่มหรือปล่อยให้ทำงาน (ดูส่วน "ล้างข้อมูล" ในตอนท้ายของเอกสารนี้) ดูข้อมูลเกี่ยวกับราคาของ Google Cloud Spanner ได้ที่นี่
ผู้ใช้ใหม่ของ Google Cloud Platform จะมีสิทธิ์ทดลองใช้ฟรี$300 ซึ่งจะทำให้ Codelab นี้ไม่มีค่าใช้จ่ายทั้งหมด
การตั้งค่า Google Cloud Shell
แม้ว่า Google Cloud และ Spanner จะทำงานจากระยะไกลได้จากแล็ปท็อป แต่ใน Codelab นี้ เราจะใช้ Google Cloud Shell ซึ่งเป็นสภาพแวดล้อมแบบบรรทัดคำสั่งที่ทำงานในระบบคลาวด์
เครื่องเสมือนแบบ Debian นี้เต็มไปด้วยเครื่องมือการพัฒนาทั้งหมดที่คุณต้องการ โดยมีไดเรกทอรีหลักขนาด 5 GB ที่ทำงานอย่างต่อเนื่องใน Google Cloud ซึ่งจะช่วยเพิ่มประสิทธิภาพของเครือข่ายและการตรวจสอบสิทธิ์ได้อย่างมาก ซึ่งหมายความว่าสิ่งที่คุณต้องมีสำหรับ Codelab นี้คือเบราว์เซอร์ (ใช่แล้ว ทั้งหมดนี้ทำงานได้บน Chromebook)
- หากต้องการเปิดใช้งาน Cloud Shell จาก Cloud Console เพียงคลิกเปิดใช้งาน 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 ข้อมูลนี้อิงตามรุ่น 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 แบบใน "โปรแกรมสร้าง" ไดเรกทอรีที่เราจะเน้น ได้แก่
- authentication_server.py: มีงานสร้างผู้เล่น และให้ผู้เล่นแบบสุ่มเลียนแบบการค้นหาจุดเดียว
- match_server.py: มีงานสำหรับสร้างเกมและปิดเกม การสร้างเกมจะกำหนดผู้เล่นแบบสุ่ม 100 คนที่ไม่ได้เล่นเกมอยู่ การปิดเกมจะอัปเดตสถิติ games_played และ games_won และอนุญาตให้กำหนดผู้เล่นเหล่านั้นในเกมในอนาคต
คุณจะต้องมี Python 3.7 ขึ้นไปเพื่อให้ Locust ทำงานใน Cloud Shell 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 และดาวน์โหลดโค้ดสำหรับห้องทดลองนี้
สุดท้าย คุณตั้งค่า Locust สำหรับการสร้างภาระงานภายหลังใน Lab
รายการถัดไป
ต่อไปคุณจะตั้งค่าอินสแตนซ์และฐานข้อมูล Cloud Spanner
3 สร้างอินสแตนซ์และฐานข้อมูล Spanner
สร้างอินสแตนซ์ Spanner
ในขั้นตอนนี้ เราจะตั้งค่าอินสแตนซ์ Spanner สำหรับ Codelab ค้นหารายการ Spanner ในเมนูแฮมเบอร์เกอร์
ด้านบนซ้าย หรือค้นหา Spanner โดยกด "/" และพิมพ์ "SPANer"
จากนั้นคลิก และกรอกแบบฟอร์มโดยป้อนชื่ออินสแตนซ์
cloudspanner-gaming
สำหรับอินสแตนซ์ เลือกการกำหนดค่า (เลือกอินสแตนซ์ระดับภูมิภาค เช่น us-central1
) แล้วกำหนดจำนวนโหนด สำหรับ Codelab นี้ เราจะใช้เพียง 500 processing units
เท่านั้น
สุดท้ายแต่ไม่ท้ายสุด ให้คลิก "สร้าง" และคุณก็มีอินสแตนซ์ Cloud Spanner อยู่แล้วภายในไม่กี่วินาที
สร้างฐานข้อมูลและสคีมา
คุณสร้างฐานข้อมูลได้เมื่ออินสแตนซ์ทำงาน Spanner ช่วยให้สามารถใช้ฐานข้อมูลได้หลายฐานข้อมูลในอินสแตนซ์เดียว
ฐานข้อมูลคือที่ที่คุณกำหนดสคีมา อีกทั้งยังควบคุมผู้ที่มีสิทธิ์เข้าถึงฐานข้อมูล ตั้งค่าการเข้ารหัสที่กำหนดเอง กำหนดค่าเครื่องมือเพิ่มประสิทธิภาพ และตั้งค่าระยะเวลาเก็บรักษาได้ด้วย
ในอินสแตนซ์หลายภูมิภาค คุณยังกำหนดค่าตัวแปรเริ่มต้นเริ่มต้นได้ด้วย อ่านเพิ่มเติมเกี่ยวกับฐานข้อมูลบน Spanner
สำหรับ Code-lab นี้ คุณจะสร้างฐานข้อมูลที่มีตัวเลือกเริ่มต้น และให้สคีมาในขณะที่สร้าง
ห้องทดลองนี้จะสร้างตาราง 2 ตาราง ได้แก่ ผู้เล่นและเกม
ผู้เล่นจะเข้าร่วมการแข่งขันหลายรายการได้ในช่วงเวลาหนึ่งๆ แต่จะเข้าร่วมได้เพียงครั้งละ 1 เกม ผู้เล่นยังมีสถิติเป็นประเภทข้อมูล JSON เพื่อติดตามสถิติที่น่าสนใจ เช่น games_played และ games_won เนื่องจากอาจมีการเพิ่มสถิติอื่นๆ เข้ามาภายหลัง นี่จึงเป็นคอลัมน์ที่ไม่มีสคีมาสำหรับผู้เล่น
เกมจะติดตามผู้เล่นที่เข้าร่วมโดยใช้ประเภทข้อมูล ARRAY ของ Spanner ระบบจะไม่ป้อนข้อมูลแอตทริบิวต์ผู้ชนะและรายการที่เสร็จสิ้นของเกมจนกว่าเกมจะปิดลง
มีคีย์นอก 1 คีย์ที่ช่วยให้แน่ใจว่า current_game ของผู้เล่นเป็นเกมที่ถูกต้อง
สร้างฐานข้อมูลด้วยการคลิก "สร้างฐานข้อมูล" ในภาพรวมของอินสแตนซ์
จากนั้นกรอกรายละเอียด ตัวเลือกที่สำคัญคือชื่อฐานข้อมูลและภาษา ในตัวอย่างนี้ เราตั้งชื่อฐานข้อมูลว่า sample-game และเลือกภาษา 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 เพื่อใช้ในภายหลังในห้องทดลองโค้ด ดังนั้น ให้จดรหัสอินสแตนซ์ และตั้งค่า 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 และฐานข้อมูลเกมตัวอย่าง คุณยังกำหนดสคีมาที่เกมตัวอย่างนี้ใช้ได้อีกด้วย
รายการถัดไป
ถัดไป คุณจะทำให้บริการโปรไฟล์ใช้งานได้เพื่อให้ผู้เล่นลงชื่อสมัครใช้เพื่อเล่นเกมได้
4 ทำให้บริการโปรไฟล์ใช้งานได้
ภาพรวมของบริการ
บริการโปรไฟล์คือ REST API ที่เขียนใน Go ซึ่งใช้ประโยชน์จากเฟรมเวิร์ก Gin
ผู้เล่นสามารถลงชื่อสมัครใช้เพื่อเล่นเกมได้ใน API นี้ ซึ่งสร้างโดยคำสั่ง POST พื้นฐานที่ยอมรับชื่อโปรแกรมเล่น อีเมล และรหัสผ่าน รหัสผ่านจะเข้ารหัสด้วย bcrypt และระบบจะเก็บแฮชไว้ในฐานข้อมูล
อีเมลจะถือว่าเป็นตัวระบุที่ไม่ซ้ำกัน ในขณะที่ player_name จะใช้เพื่อแสดงสำหรับเกม
ปัจจุบัน API นี้ไม่สามารถจัดการการเข้าสู่ระบบ แต่คุณสามารถดำเนินการเพิ่มเติมให้กับคุณได้
ไฟล์ ./src/golang/profile-service/main.go สำหรับบริการโปรไฟล์จะแสดงปลายทางหลัก 2 รายการดังนี้
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 การดำเนินการนี้จะดาวน์โหลดทรัพยากร Dependency และสร้างบริการที่ทำงานอยู่บนพอร์ต 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 ติดตั้งใช้งานบริการจับคู่
ภาพรวมของบริการ
บริการจับคู่คือ REST API ที่เขียนขึ้นใน Go ซึ่งใช้ประโยชน์จากเฟรมเวิร์ก Gin
เกมใน API นี้สร้างและปิดไปแล้ว เมื่อสร้างเกม ระบบจะกำหนดผู้เล่น 10 คนที่ไม่ได้เล่นเกมอยู่ให้เกมนั้น
เมื่อเกมปิดลง ระบบจะสุ่มเลือกผู้ชนะและผู้เล่นแต่ละคน มีการปรับค่าสถิติสำหรับ games_played และ games_won อยู่ นอกจากนี้ ผู้เล่นแต่ละคนจะได้รับการอัปเดตเพื่อระบุว่าไม่ได้เล่นแล้วและสามารถเล่นเกมในอนาคตได้
ไฟล์ ./src/golang/matchmaking-service/main.go สำหรับบริการจับคู่จะใช้การตั้งค่าและโค้ดที่คล้ายกับบริการ profile จึงไม่สามารถซ้ำกันที่นี่ บริการนี้แสดงปลายทางหลัก 2 รายการดังนี้
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())
}
บริการนี้มีโครงสร้างเกม รวมถึงโครงสร้างผู้เล่นและสถิติผู้เล่นที่เล็กลง
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 คนที่ยังไม่ได้เล่นเกมอยู่
การกลายพันธุ์ของ Spaner จะเลือกมาสร้างเกมและกำหนดผู้เล่น เนื่องจากการกลายพันธุ์จะมีประสิทธิภาพมากกว่า 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 บริการนี้มีทรัพยากร Dependency เหมือนกับบริการโปรไฟล์หลายรายการ ดังนั้นระบบจะไม่ดาวน์โหลดทรัพยากร Dependency ใหม่
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 มีอินเทอร์เฟซเว็บสำหรับเรียกใช้โปรแกรมสร้าง แต่ในห้องทดลองนี้ คุณจะใช้บรรทัดคำสั่ง (ตัวเลือก –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'")
ชื่อผู้เล่น อีเมล และรหัสผ่านจะสร้างขึ้นแบบสุ่ม
ระบบจะดึงข้อมูลผู้เล่นที่ลงชื่อสมัครใช้สำเร็จแล้วในงานที่ 2 เพื่อสร้างภาระงานการอ่าน
@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) โดยมีการเกิดขึ้นพร้อมกัน 2 ชุดข้อความพร้อมกัน (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 (open:close) คำสั่งนี้จะเรียกใช้โปรแกรมสร้างเป็นเวลา 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 Console ในการส่งคำขอการค้นหาไปยัง 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 Console เพื่อค้นหา Spanner
ขั้นตอนถัดไป
ต่อไปก็ถึงเวลาล้างข้อมูล
8 กำลังล้างข้อมูล (ไม่บังคับ)
หากต้องการล้างข้อมูล เพียงไปที่ส่วน Cloud Spanner ของ Cloud Console แล้วลบอินสแตนซ์ 'cloudspanner-gaming' ที่เราสร้างในขั้นตอน Codelab ที่ชื่อ "Setup a Cloud Spannerอินสแตนซ์"
9 ยินดีด้วย
ขอแสดงความยินดี คุณทำให้เกมตัวอย่างใช้งานได้ใน Spanner เรียบร้อยแล้ว
ขั้นตอนถัดไปคือ
ในห้องทดลองนี้ คุณได้รู้จักกับหัวข้อต่างๆ เกี่ยวกับการทำงานร่วมกับ Spanner โดยใช้ไดรเวอร์โกลัง ซึ่งจะให้พื้นฐานที่ดียิ่งขึ้นเพื่อทำความเข้าใจแนวคิดสำคัญ เช่น
- การออกแบบสคีมา
- DML เทียบกับการเปลี่ยนแปลง
- การทำงานร่วมกับ Golang
อย่าลืมดู Codelab ของ Cloud Spanner Game Trading Post เพื่อใช้เป็นตัวอย่างสำหรับการทำงานกับ Spanner เป็นแบ็กเอนด์สำหรับเกมของคุณ