1. Giới thiệu
Cloud Spanner là một dịch vụ cơ sở dữ liệu quan hệ được quản lý hoàn toàn, có thể mở rộng theo chiều ngang, phân phối trên toàn cầu, cung cấp các giao dịch ACID và ngữ nghĩa SQL mà không làm giảm hiệu suất và khả năng đáp ứng cao.
Những tính năng này giúp Spanner trở thành lựa chọn phù hợp trong cấu trúc của những trò chơi muốn có cơ sở người chơi trên toàn cầu hoặc lo ngại về tính nhất quán của dữ liệu
Trong phòng thí nghiệm này, bạn sẽ tạo 2 dịch vụ Go tương tác với cơ sở dữ liệu Spanner theo khu vực để cho phép người chơi đăng ký và bắt đầu chơi.

Tiếp theo, bạn sẽ tạo dữ liệu bằng cách tận dụng khung tải Python Locust.io để mô phỏng người chơi đăng ký và chơi trò chơi. Sau đó, bạn sẽ truy vấn Spanner để xác định số lượng người chơi đang chơi và một số số liệu thống kê về số trận thắng so với số trận đã chơi của người chơi.
Cuối cùng, bạn sẽ dọn dẹp các tài nguyên đã được tạo trong phòng thí nghiệm này.
Sản phẩm bạn sẽ tạo ra
Trong phần này, bạn sẽ:
- Tạo một phiên bản Spanner
- Triển khai một dịch vụ Hồ sơ được viết bằng Go để xử lý việc đăng ký của người chơi
- Triển khai dịch vụ tìm kiếm người chơi được viết bằng Go để chỉ định người chơi vào trò chơi, xác định người chiến thắng và cập nhật số liệu thống kê về trò chơi của người chơi.
Kiến thức bạn sẽ học được
- Cách thiết lập một phiên bản Cloud Spanner
- Cách tạo cơ sở dữ liệu và giản đồ trò chơi
- Cách triển khai các ứng dụng Go để làm việc với Cloud Spanner
- Cách tạo dữ liệu bằng Locust
- Cách truy vấn dữ liệu trong Cloud Spanner để trả lời các câu hỏi về trò chơi và người chơi.
Bạn cần có
2. Thiết lập và yêu cầu
Tạo dự án
Nếu chưa có Tài khoản Google (Gmail hoặc Google Apps), bạn phải tạo một tài khoản. Đăng nhập vào bảng điều khiển Google Cloud Platform ( console.cloud.google.com) rồi tạo một dự án mới.
Nếu bạn đã có dự án, hãy nhấp vào trình đơn thả xuống chọn dự án ở phía trên bên trái của bảng điều khiển:

rồi nhấp vào nút "DỰ ÁN MỚI" trong hộp thoại xuất hiện để tạo một dự án mới:

Nếu chưa có dự án, bạn sẽ thấy một hộp thoại như thế này để tạo dự án đầu tiên:

Hộp thoại tạo dự án tiếp theo cho phép bạn nhập thông tin chi tiết về dự án mới:

Hãy nhớ mã dự án. Đây là tên duy nhất trên tất cả các dự án Google Cloud (tên ở trên đã được sử dụng và sẽ không hoạt động đối với bạn, xin lỗi!). Sau này trong lớp học lập trình này, chúng ta sẽ gọi đó là PROJECT_ID.
Tiếp theo, nếu chưa làm, bạn cần phải bật tính năng thanh toán trong Play Console để sử dụng các tài nguyên của Google Cloud và bật Cloud Spanner API.

Việc thực hiện lớp học lập trình này sẽ không tốn của bạn quá vài đô la, nhưng có thể tốn nhiều hơn nếu bạn quyết định sử dụng nhiều tài nguyên hơn hoặc nếu bạn để các tài nguyên đó chạy (xem phần "dọn dẹp" ở cuối tài liệu này). Bạn có thể xem tài liệu về giá của Google Cloud Spanner tại đây.
Người dùng mới của Google Cloud Platform đủ điều kiện dùng thử miễn phí trị giá 300 USD. Nhờ đó, bạn có thể hoàn toàn miễn phí tham gia lớp học lập trình này.
Thiết lập Google Cloud Shell
Mặc dù bạn có thể vận hành Google Cloud và Spanner từ xa trên máy tính xách tay, nhưng trong lớp học lập trình này, chúng ta sẽ sử dụng Google Cloud Shell, một môi trường dòng lệnh chạy trên đám mây.
Máy ảo dựa trên Debian này được trang bị tất cả các công cụ phát triển mà bạn cần. Nền tảng này cung cấp một thư mục chính có dung lượng 5 GB và chạy trong Google Cloud, giúp tăng cường đáng kể hiệu suất mạng và hoạt động xác thực. Điều này có nghĩa là bạn chỉ cần một trình duyệt (có, trình duyệt này hoạt động trên Chromebook) cho lớp học lập trình này.
- Để kích hoạt Cloud Shell từ Bảng điều khiển Cloud, bạn chỉ cần nhấp vào biểu tượng Kích hoạt Cloud Shell
(mất vài phút để cung cấp và kết nối với môi trường).


Sau khi kết nối với Cloud Shell, bạn sẽ thấy rằng mình đã được xác thực và dự án đã được đặt thành PROJECT_ID.
gcloud auth list
Đầu ra của lệnh
Credentialed accounts:
- <myaccount>@<mydomain>.com (active)
gcloud config list project
Đầu ra của lệnh
[core]
project = <PROJECT_ID>
Nếu vì lý do nào đó mà dự án chưa được thiết lập, bạn chỉ cần đưa ra lệnh sau:
gcloud config set project <PROJECT_ID>
Bạn đang tìm PROJECT_ID? Kiểm tra mã nhận dạng bạn đã dùng trong các bước thiết lập hoặc tìm mã nhận dạng đó trong trang tổng quan của Cloud Console:

Cloud Shell cũng đặt một số biến môi trường theo mặc định, có thể hữu ích khi bạn chạy các lệnh trong tương lai.
echo $GOOGLE_CLOUD_PROJECT
Đầu ra của lệnh
<PROJECT_ID>
Tải mã xuống
Trong Cloud Shell, bạn có thể tải mã xuống cho bài tập thực hành này. Đây là bản phát hành v0.1.0, vì vậy hãy kiểm tra thẻ đó:
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
Đầu ra của lệnh
Cloning into 'spanner-gaming-sample'...
*snip*
Switched to a new branch 'v0.1.0-branch'
Thiết lập trình tạo tải Locust
Locust là một khung kiểm thử tải Python hữu ích để kiểm thử các điểm cuối API REST. Trong lớp học lập trình này, chúng ta có 2 kiểm thử tải khác nhau trong thư mục "generators" mà chúng ta sẽ làm nổi bật:
- authentication_server.py: chứa các tác vụ tạo người chơi và lấy một người chơi ngẫu nhiên để bắt chước các lượt tra cứu một điểm.
- match_server.py: chứa các tác vụ tạo trò chơi và đóng trò chơi. Khi bạn tạo trò chơi, hệ thống sẽ chỉ định 100 người chơi ngẫu nhiên hiện không chơi trò chơi. Việc đóng trò chơi sẽ cập nhật số liệu thống kê về số trò chơi đã chơi và số trò chơi đã thắng, đồng thời cho phép chỉ định những người chơi đó vào một trò chơi trong tương lai.
Để chạy Locust trong Cloud Shell, bạn cần có Python 3.7 trở lên. Cloud Shell đi kèm với Python 3.9, vì vậy, bạn chỉ cần xác thực phiên bản:
python -V
Đầu ra của lệnh
Python 3.9.12
Giờ đây, bạn có thể cài đặt các yêu cầu cho Locust.
pip3 install -r requirements.txt
Đầu ra của lệnh
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
Bây giờ, hãy cập nhật PATH để có thể tìm thấy tệp nhị phân locust mới cài đặt:
PATH=~/.local/bin":$PATH"
which locust
Đầu ra của lệnh
/home/<user>/.local/bin/locust
Tóm tắt
Trong bước này, bạn đã thiết lập dự án (nếu chưa có), kích hoạt Cloud Shell và tải mã xuống cho lớp học này.
Cuối cùng, bạn sẽ thiết lập Locust để tạo tải sau trong phòng thí nghiệm.
Tiếp theo
Tiếp theo, bạn sẽ thiết lập phiên bản và cơ sở dữ liệu Cloud Spanner.
3. Tạo một thực thể và cơ sở dữ liệu Spanner
Tạo phiên bản Spanner
Trong bước này, chúng ta sẽ thiết lập phiên bản Spanner cho lớp học lập trình. Tìm mục Spanner
trong Trình đơn hình bánh hamburger ở trên cùng bên trái
hoặc tìm Spanner bằng cách nhấn "/" rồi nhập "Spanner"

Tiếp theo, hãy nhấp vào
rồi điền vào biểu mẫu bằng cách nhập tên phiên bản cloudspanner-gaming cho phiên bản của bạn, chọn một cấu hình (chọn một phiên bản theo khu vực, chẳng hạn như us-central1) và đặt số lượng nút. Trong lớp học lập trình này, chúng ta sẽ chỉ cần 500 processing units.
Cuối cùng, hãy nhấp vào "Tạo" và trong vài giây, bạn sẽ có một phiên bản Cloud Spanner theo ý mình.

Tạo cơ sở dữ liệu và giản đồ
Sau khi phiên bản của bạn đang chạy, bạn có thể tạo cơ sở dữ liệu. Spanner cho phép có nhiều cơ sở dữ liệu trên một phiên bản duy nhất.
Cơ sở dữ liệu là nơi bạn xác định giản đồ. Bạn cũng có thể kiểm soát những người có quyền truy cập vào cơ sở dữ liệu, thiết lập chế độ mã hoá tuỳ chỉnh, định cấu hình trình tối ưu hoá và đặt khoảng thời gian lưu giữ.
Trên các phiên bản đa khu vực, bạn cũng có thể định cấu hình người dẫn đầu mặc định. Đọc thêm về cơ sở dữ liệu trên Spanner.
Trong lớp học lập trình này, bạn sẽ tạo cơ sở dữ liệu bằng các lựa chọn mặc định và cung cấp giản đồ tại thời điểm tạo.
Phòng thí nghiệm này sẽ tạo ra 2 bảng: players và games.

Người chơi có thể tham gia nhiều trò chơi theo thời gian, nhưng chỉ được tham gia một trò chơi tại một thời điểm. Người chơi cũng có số liệu thống kê dưới dạng kiểu dữ liệu JSON để theo dõi các số liệu thống kê thú vị như games_played và games_won. Vì các số liệu thống kê khác có thể được thêm vào sau này, nên đây là một cột không có lược đồ cho người chơi.
Trò chơi theo dõi những người chơi đã tham gia bằng cách sử dụng kiểu dữ liệu ARRAY của Spanner. Thuộc tính người chiến thắng và thuộc tính đã kết thúc của một trận đấu sẽ không được điền cho đến khi trận đấu kết thúc.
Có một khoá ngoại để đảm bảo current_game của người chơi là một trò chơi hợp lệ.
Bây giờ, hãy tạo cơ sở dữ liệu bằng cách nhấp vào "Tạo cơ sở dữ liệu" trong phần tổng quan về phiên bản:

Sau đó, hãy điền thông tin chi tiết. Các lựa chọn quan trọng là tên cơ sở dữ liệu và phương ngữ. Trong ví dụ này, chúng ta đặt tên cho cơ sở dữ liệu là sample-game và chọn phương ngữ SQL chuẩn của Google.
Đối với giản đồ, hãy sao chép và dán DDL này vào hộp:
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);
Sau đó, hãy nhấp vào nút tạo và đợi vài giây để cơ sở dữ liệu của bạn được tạo.
Trang tạo cơ sở dữ liệu sẽ có dạng như sau:

Bây giờ, bạn cần thiết lập một số biến môi trường trong Cloud Shell để sử dụng sau này trong lớp học lập trình. Vì vậy, hãy ghi lại instance-id và đặt INSTANCE_ID cũng như DATABASE_ID trong Cloud Shell

export SPANNER_PROJECT_ID=$GOOGLE_CLOUD_PROJECT
export SPANNER_INSTANCE_ID=cloudspanner-gaming
export SPANNER_DATABASE_ID=sample-game
Tóm tắt
Ở bước này, bạn đã tạo một phiên bản Spanner và cơ sở dữ liệu sample-game. Bạn cũng đã xác định giản đồ mà trò chơi mẫu này sử dụng.
Tiếp theo
Tiếp theo, bạn sẽ triển khai dịch vụ hồ sơ để cho phép người chơi đăng ký chơi trò chơi!
4. Triển khai dịch vụ hồ sơ
Tổng quan về dịch vụ
Dịch vụ hồ sơ là một API REST được viết bằng Go, tận dụng khung gin.

Trong API này, người chơi có thể đăng ký chơi trò chơi. Thao tác này được tạo bằng một lệnh POST đơn giản chấp nhận tên, email và mật khẩu của người chơi. Mật khẩu được mã hoá bằng bcrypt và hàm băm được lưu trữ trong cơ sở dữ liệu.
Email được coi là giá trị nhận dạng duy nhất, trong khi player_name được dùng cho mục đích hiển thị trong trò chơi.
API này hiện không xử lý việc đăng nhập, nhưng bạn có thể tự triển khai việc này như một bài tập bổ sung.
Tệp ./src/golang/profile-service/main.go cho dịch vụ hồ sơ sẽ hiển thị 2 điểm cuối chính như sau:
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())
}
Và mã cho những điểm cuối đó sẽ chuyển đến mô hình player.
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)
}
Một trong những việc đầu tiên mà dịch vụ này làm là thiết lập kết nối Spanner. Thao tác này được triển khai ở cấp dịch vụ để tạo nhóm phiên cho dịch vụ.
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 và PlayerStats là các cấu trúc được xác định như sau:
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"`
}
Hàm thêm người chơi tận dụng một thao tác chèn DML bên trong giao dịch ReadWrite, vì việc thêm người chơi là một câu lệnh duy nhất chứ không phải thao tác chèn hàng loạt. Hàm này có dạng như sau:
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
}
Để truy xuất một người chơi dựa trên UUID của họ, một thao tác đọc đơn giản sẽ được thực hiện. Thao tác này sẽ truy xuất playerUUID, player_name, email và số liệu thống kê của người chơi.
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
}
Theo mặc định, dịch vụ này được định cấu hình bằng các biến môi trường. Hãy xem phần có liên quan trong tệp ./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
}
Bạn có thể thấy rằng hành vi mặc định là chạy dịch vụ trên localhost:8080.
Đã đến lúc chạy dịch vụ này.
Chạy dịch vụ hồ sơ
Chạy dịch vụ bằng lệnh go. Thao tác này sẽ tải các phần phụ thuộc xuống và thiết lập dịch vụ chạy trên cổng 8080:
cd ~/spanner-gaming-sample/src/golang/profile-service
go run . &
Đầu ra của lệnh:
[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
Kiểm thử dịch vụ bằng cách đưa ra lệnh 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"}'
Đầu ra của lệnh:
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"
Tóm tắt
Trong bước này, bạn đã triển khai dịch vụ hồ sơ cho phép người chơi đăng ký chơi trò chơi của bạn và bạn đã thử nghiệm dịch vụ này bằng cách đưa ra một lệnh gọi API POST để tạo người chơi mới.
Các bước tiếp theo
Trong bước tiếp theo, bạn sẽ triển khai dịch vụ mai mối.
5. Triển khai dịch vụ mai mối
Tổng quan về dịch vụ
Dịch vụ mai mối là một API REST được viết bằng Go, tận dụng khung gin.

Trong API này, các trò chơi được tạo và đóng. Khi một trò chơi được tạo, 10 người chơi hiện không chơi trò chơi nào sẽ được chỉ định cho trò chơi đó.
Khi một trò chơi kết thúc, người chiến thắng sẽ được chọn ngẫu nhiên và số liệu thống kê của mỗi người chơi về games_played (số trận đã chơi) và games_won (số trận đã thắng) sẽ được điều chỉnh. Ngoài ra, mỗi người chơi sẽ được cập nhật để cho biết họ không còn chơi nữa và có thể chơi các trận đấu trong tương lai.
Tệp ./src/golang/matchmaking-service/main.go cho dịch vụ tìm kiếm người chơi có chế độ thiết lập và mã tương tự như dịch vụ profile, nên không được lặp lại ở đây. Dịch vụ này cung cấp 2 điểm cuối chính như sau:
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())
}
Dịch vụ này cung cấp một cấu trúc Game, cũng như các cấu trúc Player và PlayerStats được tinh giản:
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"`
}
Để tạo một trò chơi, dịch vụ tìm kiếm người chơi sẽ chọn ngẫu nhiên 100 người chơi hiện không chơi trò chơi.
Các đột biến Spanner được chọn để tạo trò chơi và chỉ định người chơi, vì các đột biến có hiệu suất cao hơn DML đối với các thay đổi lớn.
// 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
}
Việc chọn ngẫu nhiên người chơi được thực hiện bằng SQL thông qua khả năng TABLESPACE RESERVOIR của GoogleSQL.
Việc đóng một trò chơi sẽ phức tạp hơn một chút. Thao tác này bao gồm việc chọn ngẫu nhiên người chiến thắng trong số những người chơi, đánh dấu thời gian kết thúc trò chơi và cập nhật số liệu thống kê của từng người chơi cho games_played và games_won.
Do sự phức tạp này và số lượng thay đổi, các đột biến lại được chọn để kết thúc trò chơi.
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
}
Cấu hình lại được xử lý thông qua các biến môi trường như mô tả trong ./src/golang/matchmaking-service/config/config.go cho dịch vụ.
// 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")
Để tránh xung đột với profile-service, dịch vụ này chạy trên localhost:8081 theo mặc định.
Với thông tin này, giờ là lúc chạy dịch vụ tìm kiếm người chơi.
Chạy dịch vụ mai mối
Chạy dịch vụ bằng lệnh go. Thao tác này sẽ thiết lập dịch vụ chạy trên cổng 8082. Dịch vụ này có nhiều phần phụ thuộc giống như profile-service, nên các phần phụ thuộc mới sẽ không được tải xuống.
cd ~/spanner-gaming-sample/src/golang/matchmaking-service
go run . &
Đầu ra của lệnh:
[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
Tạo trò chơi
Thử nghiệm dịch vụ để tạo một trò chơi. Trước tiên, hãy mở một cửa sổ dòng lệnh mới trong Cloud Shell:

Sau đó, hãy phát lệnh curl sau:
curl http://localhost:8081/games/create \
--include \
--header "Content-Type: application/json" \
--request "POST"
Đầu ra của lệnh:
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"
Đóng trò chơi
curl http://localhost:8081/games/close \
--include \
--header "Content-Type: application/json" \
--data '{"gameUUID": "f45b0f7f-405b-4e67-a3b8-a624e990285d"}' \
--request "PUT"
Đầu ra của lệnh:
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"
Tóm tắt
Ở bước này, bạn đã triển khai dịch vụ mai mối để xử lý việc tạo trò chơi và chỉ định người chơi cho trò chơi đó. Dịch vụ này cũng xử lý việc kết thúc một trò chơi, chọn một người chiến thắng ngẫu nhiên và cập nhật số liệu thống kê của tất cả người chơi cho games_played và games_won.
Các bước tiếp theo
Giờ đây, khi các dịch vụ của bạn đang chạy, đã đến lúc người chơi đăng ký và chơi trò chơi!
6. Bắt đầu phát
Giờ đây, khi các dịch vụ hồ sơ và tìm kiếm người chơi đang chạy, bạn có thể tạo tải bằng cách sử dụng các trình tạo locust được cung cấp.
Locust cung cấp một giao diện web để chạy các trình tạo, nhưng trong phòng thí nghiệm này, bạn sẽ sử dụng dòng lệnh (lựa chọn –headless).
Đăng ký người chơi
Trước tiên, bạn sẽ muốn tạo người chơi.
Mã Python để tạo người chơi trong tệp ./generators/authentication_server.py có dạng như sau:
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'")
Tên, email và mật khẩu của người chơi được tạo ngẫu nhiên.
Những người chơi đăng ký thành công sẽ được truy xuất bằng một tác vụ thứ hai để tạo tải đọc.
@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]")
Lệnh sau đây gọi tệp ./generators/authentication_server.py sẽ tạo người chơi mới trong 30 giây (t=30s) với mức độ đồng thời là 2 luồng tại một thời điểm (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
Người chơi tham gia trò chơi
Giờ đây, khi người chơi đã đăng ký, họ muốn bắt đầu chơi trò chơi!
Mã Python để tạo và đóng các trò chơi trong tệp ./generators/match_server.py có dạng như sau:
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)
Khi trình tạo này chạy, nó sẽ mở và đóng các trò chơi theo tỷ lệ 2:1 (mở:đóng). Lệnh này sẽ chạy trình tạo trong 10 giây (-t=10s):
locust -H http://127.0.0.1:8081 -f ./generators/match_server.py --headless -u=1 -r=1 -t=10s
Tóm tắt
Ở bước này, bạn đã mô phỏng người chơi đăng ký chơi trò chơi, sau đó chạy mô phỏng để người chơi chơi trò chơi bằng dịch vụ tìm kiếm người chơi. Những mô phỏng này tận dụng khung Locust Python để đưa ra các yêu cầu cho API REST của dịch vụ.
Bạn có thể sửa đổi thời gian tạo người chơi và chơi trò chơi, cũng như số lượng người dùng đồng thời (-u).
Các bước tiếp theo
Sau khi mô phỏng, bạn sẽ muốn kiểm tra nhiều số liệu thống kê bằng cách truy vấn Spanner.
7. Truy xuất số liệu thống kê về trò chơi
Giờ đây, khi người chơi mô phỏng có thể đăng ký và chơi trò chơi, bạn nên kiểm tra số liệu thống kê của mình.
Để thực hiện việc này, hãy dùng Cloud Console để đưa ra các yêu cầu truy vấn đến Spanner.

Kiểm tra các trận đấu đang mở và đã kết thúc
Trận đấu đã kết thúc là trận đấu có dấu thời gian finished, còn trận đấu chưa kết thúc sẽ có giá trị finished là NULL. Giá trị này được đặt khi trò chơi đóng.
Vì vậy, truy vấn này sẽ cho phép bạn kiểm tra số lượng trò chơi đang mở và số lượng trò chơi đã đóng:
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
)
Kết quả:
|
|
|
|
|
|
Kiểm tra số lượng người chơi đang chơi so với số lượng người chơi không chơi
Người chơi đang chơi một trò chơi nếu cột current_game của họ được đặt. Nếu không, tức là họ hiện không chơi trò chơi.
Để so sánh số lượng người chơi đang chơi và không chơi, hãy sử dụng truy vấn này:
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
)
Kết quả:
|
|
|
|
|
|
Xác định người chiến thắng hàng đầu
Khi một trận đấu kết thúc, một người chơi sẽ được chọn ngẫu nhiên để trở thành người chiến thắng. Thống kê games_won của người chơi đó sẽ tăng lên khi kết thúc trò chơi.
SELECT playerUUID, stats
FROM players
WHERE CAST(JSON_VALUE(stats, "$.games_won") AS INT64)>0
LIMIT 10;
Kết quả:
playerUUID | số liệu thống kê |
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} |
Tóm tắt
Trong bước này, bạn đã xem xét nhiều số liệu thống kê về người chơi và trò chơi bằng cách sử dụng Cloud Console để truy vấn Spanner.
Các bước tiếp theo
Tiếp theo, đã đến lúc dọn dẹp!
8. Dọn dẹp (không bắt buộc)
Để dọn dẹp, bạn chỉ cần chuyển đến mục Cloud Spanner của Cloud Console rồi xoá phiên bản "cloudspanner-gaming" mà chúng ta đã tạo trong bước của lớp học lập trình có tên là "Thiết lập một phiên bản Cloud Spanner".
9. Xin chúc mừng!
Xin chúc mừng! Bạn đã triển khai thành công một trò chơi mẫu trên Spanner
Tiếp theo là gì?
Trong phòng thí nghiệm này, bạn đã được giới thiệu về nhiều chủ đề liên quan đến việc sử dụng Spanner bằng trình điều khiển golang. Nhờ đó, bạn có thể hiểu rõ hơn về các khái niệm quan trọng như:
- Thiết kế giản đồ
- DML so với Mutations
- Làm việc với Golang
Nhớ xem lớp học lập trình Cloud Spanner Game Trading Post để biết một ví dụ khác về cách sử dụng Spanner làm phần phụ trợ cho trò chơi của bạn!