1. บทนำ
Cloud Spanner เป็นบริการฐานข้อมูลเชิงสัมพันธ์ที่มีการจัดการครบวงจรซึ่งปรับขนาดได้ในแนวนอนและกระจายอยู่ทั่วโลก โดยมีธุรกรรม ACID และความหมายของ SQL โดยไม่ลดทอนประสิทธิภาพและความพร้อมใช้งานสูง
ฟีเจอร์เหล่านี้ทำให้ Spanner เหมาะอย่างยิ่งกับสถาปัตยกรรมของเกมที่ต้องการเปิดใช้งานฐานผู้เล่นทั่วโลกหรือกังวลเกี่ยวกับความสอดคล้องของข้อมูล
ในแล็บนี้ คุณจะได้สร้างบริการ Go 2 รายการที่โต้ตอบกับฐานข้อมูล 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.cloud.google.com) แล้วสร้างโปรเจ็กต์ใหม่
หากมีโปรเจ็กต์อยู่แล้ว ให้คลิกเมนูแบบเลื่อนลงเพื่อเลือกโปรเจ็กต์ที่ด้านซ้ายบนของคอนโซล

แล้วคลิกปุ่ม "โปรเจ็กต์ใหม่" ในกล่องโต้ตอบที่ปรากฏเพื่อสร้างโปรเจ็กต์ใหม่

หากยังไม่มีโปรเจ็กต์ คุณจะเห็นกล่องโต้ตอบแบบนี้เพื่อสร้างโปรเจ็กต์แรก

กล่องโต้ตอบการสร้างโปรเจ็กต์ในภายหลังจะช่วยให้คุณป้อนรายละเอียดของโปรเจ็กต์ใหม่ได้

โปรดจดจำรหัสโปรเจ็กต์ ซึ่งเป็นชื่อที่ไม่ซ้ำกันในโปรเจ็กต์ Google Cloud ทั้งหมด (ชื่อด้านบนมีผู้ใช้แล้วและจะใช้ไม่ได้ ขออภัย) ซึ่งจะเรียกว่า PROJECT_ID ใน Codelab นี้ในภายหลัง
จากนั้นหากยังไม่ได้ดำเนินการ คุณจะต้องเปิดใช้การเรียกเก็บเงินใน 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 ซึ่งช่วยเพิ่มประสิทธิภาพเครือข่ายและการตรวจสอบสิทธิ์ได้อย่างมาก ซึ่งหมายความว่าคุณจะต้องมีเพียงเบราว์เซอร์เท่านั้นสำหรับโค้ดแล็บนี้ (ใช่แล้ว ใช้ได้ใน 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>
ดาวน์โหลดรหัส
คุณดาวน์โหลดโค้ดสำหรับ Lab นี้ได้ใน 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 ซึ่งมีประโยชน์ในการทดสอบปลายทาง REST API ใน 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 และดาวน์โหลดโค้ดสำหรับแล็บนี้
สุดท้าย คุณจะตั้งค่า Locust เพื่อสร้างโหลดในภายหลังในห้องทดลอง
รายการถัดไป
จากนั้นคุณจะตั้งค่าอินสแตนซ์และฐานข้อมูล Cloud Spanner
3. สร้างอินสแตนซ์และฐานข้อมูล Spanner
สร้างอินสแตนซ์ Spanner
ในขั้นตอนนี้ เราจะตั้งค่าอินสแตนซ์ Spanner สำหรับ Codelab ค้นหารายการ Spanner
ในเมนูแฮมเบอร์เกอร์ด้านซ้ายบน
หรือค้นหา Spanner โดยกด "/" แล้วพิมพ์ "Spanner"

จากนั้นคลิก
แล้วกรอกแบบฟอร์มโดยป้อนชื่ออินสแตนซ์ cloudspanner-gaming สำหรับอินสแตนซ์ เลือกการกำหนดค่า (เลือกอินสแตนซ์ระดับภูมิภาค เช่น us-central1) และตั้งค่าจำนวนโหนด สำหรับโค้ดแล็บนี้ เราจะใช้เพียง 500 processing units
สุดท้าย ให้คลิก "สร้าง" แล้วคุณจะมีอินสแตนซ์ Cloud Spanner พร้อมใช้งานภายในไม่กี่วินาที

สร้างฐานข้อมูลและสคีมา
เมื่ออินสแตนซ์ทำงานแล้ว คุณจะสร้างฐานข้อมูลได้ Spanner อนุญาตให้มีฐานข้อมูลหลายรายการในอินสแตนซ์เดียว
ฐานข้อมูลคือที่ที่คุณกำหนดสคีมา นอกจากนี้ คุณยังควบคุมผู้ที่มีสิทธิ์เข้าถึงฐานข้อมูล ตั้งค่าการเข้ารหัสที่กำหนดเอง กำหนดค่าเครื่องมือเพิ่มประสิทธิภาพ และตั้งค่าระยะเวลาเก็บรักษาได้ด้วย
ในอินสแตนซ์แบบหลายภูมิภาค คุณยังกำหนดค่าผู้นำเริ่มต้นได้ด้วย อ่านเพิ่มเติมเกี่ยวกับฐานข้อมูลใน Spanner
สำหรับโค้ดแล็บนี้ คุณจะสร้างฐานข้อมูลด้วยตัวเลือกเริ่มต้น และระบุสคีมาในเวลาที่สร้าง
โดย Lab นี้จะสร้างตาราง 2 ตาราง ได้แก่ players และ games

ผู้เล่นสามารถเข้าร่วมเกมได้หลายเกมเมื่อเวลาผ่านไป แต่จะเข้าร่วมได้ครั้งละ 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);
จากนั้นคลิกปุ่มสร้างและรอ 2-3 วินาทีเพื่อให้ระบบสร้างฐานข้อมูล
หน้าสร้างฐานข้อมูลควรมีลักษณะดังนี้

ตอนนี้คุณต้องตั้งค่าตัวแปรสภาพแวดล้อมบางอย่างใน 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. ติดตั้งใช้งานบริการโปรไฟล์
ภาพรวมของบริการ
บริการโปรไฟล์คือ REST API ที่เขียนด้วยภาษา Go ซึ่งใช้ประโยชน์จากเฟรมเวิร์ก Gin

ใน API นี้ ผู้เล่นสามารถลงชื่อสมัครใช้เพื่อเล่นเกมได้ ซึ่งสร้างขึ้นโดยใช้คำสั่ง POST ง่ายๆ ที่ยอมรับชื่อผู้เล่น อีเมล และรหัสผ่าน ระบบจะเข้ารหัสรหัสผ่านด้วย bcrypt และจัดเก็บแฮชไว้ในฐานข้อมูล
ระบบจะถือว่าอีเมลเป็นตัวระบุที่ไม่ซ้ำกัน ส่วนplayer_name จะใช้เพื่อการแสดงผลในเกม
ปัจจุบัน API นี้ยังไม่รองรับการเข้าสู่ระบบ แต่คุณสามารถเลือกใช้ 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 และสถิติของผู้เล่น
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. ติดตั้งใช้งานบริการจับคู่
ภาพรวมของบริการ
บริการจับคู่คือ REST API ที่เขียนด้วยภาษา Go ซึ่งใช้ประโยชน์จากเฟรมเวิร์ก Gin

ใน API นี้ จะมีการสร้างและปิดเกม เมื่อสร้างเกม ระบบจะกำหนดผู้เล่น 10 คนที่ไม่ได้เล่นเกมอยู่ในขณะนั้นให้เล่นเกมดังกล่าว
เมื่อปิดเกม ระบบจะเลือกผู้ชนะแบบสุ่มและปรับสถิติของผู้เล่นแต่ละคนสำหรับgames_played และ games_won นอกจากนี้ ระบบจะอัปเดตผู้เล่นแต่ละคนเพื่อระบุว่าผู้เล่นไม่ได้เล่นเกมแล้วและพร้อมที่จะเล่นเกมในอนาคต
ไฟล์ ./src/golang/matchmaking-service/main.go สำหรับบริการจับคู่มีรูปแบบการตั้งค่าและโค้ดคล้ายกับบริการโปรไฟล์ จึงไม่ต้องกล่าวซ้ำที่นี่ บริการนี้แสดงปลายทางหลัก 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())
}
บริการนี้มีโครงสร้าง 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 บริการนี้มีทรัพยากร Dependency หลายรายการเหมือนกับ profile-service จึงจะไม่มีการดาวน์โหลดทรัพยากร 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 ที่ให้ไว้
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'")
ระบบจะสร้างชื่อผู้เล่น อีเมล และรหัสผ่านแบบสุ่ม
ระบบจะเรียกข้อมูลผู้เล่นที่ลงชื่อสมัครใช้สำเร็จโดยใช้ Task ที่ 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 (เปิด:ปิด) คำสั่งนี้จะเรียกใช้เครื่องมือสร้างเป็นเวลา 10 วินาที (-t=10s):
locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s
สรุป
ในขั้นตอนนี้ คุณได้จำลองผู้เล่นที่ลงชื่อสมัครเล่นเกม แล้วจึงจำลองให้ผู้เล่นเล่นเกมโดยใช้บริการจับคู่ การจำลองเหล่านี้ใช้ประโยชน์จากเฟรมเวิร์ก Python ของ Locust เพื่อส่งคำขอไปยัง 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 ที่ชื่อ "ตั้งค่าอินสแตนซ์ Cloud Spanner"
9. ยินดีด้วย
ยินดีด้วย คุณทำให้เกมตัวอย่างใช้งานได้ใน Spanner เรียบร้อยแล้ว
ขั้นตอนต่อไปคืออะไร
ในแล็บนี้ คุณได้เรียนรู้หัวข้อต่างๆ เกี่ยวกับการทำงานกับ Spanner โดยใช้ไดรเวอร์ Golang ซึ่งจะช่วยให้คุณมีพื้นฐานที่ดีขึ้นในการทำความเข้าใจแนวคิดที่สำคัญ เช่น
- การออกแบบสคีมา
- DML เทียบกับการเปลี่ยนแปลง
- การทำงานกับ Golang
อย่าลืมดู Codelab ร้านค้าในเกมจาก Cloud Spanner เพื่อดูตัวอย่างการทำงานกับ Spanner เป็นแบ็กเอนด์สำหรับเกมอีกตัวอย่างหนึ่ง