Cloud Spanner Bắt đầu phát triển trò chơi

1. Giới thiệu

Cloud Spanner là một dịch vụ cơ sở dữ liệu quan hệ, được phân phối toàn cầu, có thể mở rộng theo chiều ngang, được quản lý toàn diện. Dịch vụ này 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 hoạt động cao.

Những tính năng này giúp Spanner rất phù hợp với kiến trúc của những trò chơi muốn thúc đẩy lượng người chơi ở quy mô 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 (Trò chơi) tương tác với cơ sở dữ liệu Spanner theo khu vực để người chơi có thể đăng ký và bắt đầu chơi.

413fdd57bb0b68bc.pngS

Tiếp theo, bạn sẽ tạo dữ liệu 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ố người chơi đang chơi và một vài số liệu thống kê về người chơi số trận thắng và số trận đã chơi.

Cuối cùng, bạn sẽ dọn dẹp các tài nguyên đã tạo trong phòng thí nghiệm này.

Sản phẩm bạn sẽ tạo ra

Trong phòng thí nghiệm này, bạn sẽ:

  • Tạo một thực thể Spanner
  • Triển khai dịch vụ Hồ sơ được viết trong Go để xử lý việc đăng ký người chơi
  • Triển khai dịch vụ mai mối được viết trong Go để chỉ định người chơi cho trò chơi, xác định người chiến thắng và cập nhật thông tin số liệu thống kê về trò chơi.

Kiến thức bạn sẽ học được

  • Cách thiết lập một thực thể Cloud Spanner
  • Cách tạo cơ sở dữ liệu và giản đồ trò chơi
  • Cách triển khai ứng dụng Go để hoạt động 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âu hỏi về trò chơi và người chơi.

Bạn cần có

  • Một dự án Google Cloud được kết nối với một tài khoản thanh toán.
  • Một trình duyệt web, chẳng hạn như Chrome hoặc Firefox.

2. Thiết lập và yêu cầu

Tạo một 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) và tạo một dự án mới.

Nếu bạn đã có một dự án, hãy nhấp vào trình đơn kéo xuống để chọn dự án ở phía trên bên trái của bảng điều khiển:

6c9406d9b014760.pngS

và nhấp vào "Dự án MỚI" trong hộp thoại kết quả để tạo một dự án mới:

949d83c8a4ee17d9.pngS

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

870a3cbd6541ee86.pngS

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:

6a92c57d3250a4b3.pngS

Xin lưu ý rằng mã dự án là tên duy nhất trong tất cả dự án Google Cloud (tên ở trên đã được sử dụng nên sẽ không phù hợp với bạn!). Mã này sẽ được đề cập sau trong lớp học lập trình này với tên PROJECT_ID.

Tiếp theo, nếu chưa bật tính năng thanh toán, bạn sẽ phải bật tính năng thanh toán trong Developers Console để có thể sử dụng tài nguyên trên Google Cloud và bật Cloud Spanner API.

15d0ef27a8fbab27.png.

Bạn sẽ không mất quá vài đô la khi chạy lớp học lập trình này, nhưng có thể sẽ cao 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 để chúng chạy (xem phần "dọn dẹp" ở cuối tài liệu này). Bạn có thể xem giá của Google Cloud Spanner tại đây.

Người dùng mới của Google Cloud Platform đủ điều kiện nhận 300 USD dùng thử miễn phí. Vì vậy, lớp học lập trình này sẽ hoàn toàn miễn phí.

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 trong Đám mây.

Máy ảo dựa trên Debian này được tải tất cả các công cụ phát triển mà bạn cần. Dịch vụ này cung cấp thư mục gốc có dung lượng ổn định 5 GB và chạy trong Google Cloud, giúp nâng cao đáng kể hiệu suất và khả năng xác thực của mạng. Tức là tất cả những gì bạn cần để thực hiện lớp học lập trình này là một trình duyệt (vâng, trình duyệt này hoạt động trên Chromebook).

  1. Để kích hoạt Cloud Shell từ Cloud Console, bạn chỉ cần nhấp vào biểu tượng Kích hoạt Cloud Shell gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A (chỉ mất vài phút để cấp phép và kết nối với môi trường).

JjEuRXGg0AYYIY6QZ8d-66gx_Mtc-_jDE9ijmbXLJSAXFvJt-qUpNtsBsYjNpv2W6BQSrDc1D-ARINNQ-1EkwUhz-iUK-FUCZhJ-NtjvIEx9pIkE-246DomWuCfiGHK78DgoeWkHRw

Ảnh chụp màn hình lúc 10:13.43 chiều 14/6/2017.png

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

Kết quả lệnh

Credentialed accounts:
 - <myaccount>@<mydomain>.com (active)
gcloud config list project

Kết quả lệnh

[core]
project = <PROJECT_ID>

Nếu vì lý do nào đó mà dự án không được thiết lập, chỉ cần phát hành lệnh sau:

gcloud config set project <PROJECT_ID>

Bạn đang tìm PROJECT_ID của mình? Hãy xem mã nhận dạng bạn đã sử dụng ở các bước thiết lập hoặc tra cứu trong trang tổng quan Cloud Console:

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

Cloud Shell cũng đặt một số biến môi trường theo mặc định. Điều này có thể hữu ích khi bạn chạy các lệnh sau này.

echo $GOOGLE_CLOUD_PROJECT

Kết quả lệnh

<PROJECT_ID>

Tải mã xuống

Trong Cloud Shell, bạn có thể tải mã cho phòng thí nghiệm này xuống. Mã này dựa trên 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

Kết quả 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 Lococ

Locus là một khung kiểm thử tải Python rất hữu ích trong việc kiểm thử các điểm cuối của API REST. Trong lớp học lập trình này, chúng ta có 2 bài kiểm thử tải trong "trình tạo" mà chúng tôi sẽ nêu bật:

  • authentication_server.py: chứa nhiệm vụ để tạo người chơi và để người chơi ngẫu nhiên bắt chước thao tác 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. Khi các trò chơi đóng, hệ thống sẽ cập nhật số liệu thống kê của games_played và games_won, đồ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 Lo xảy ra 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 không cần phải làm gì ngoài việc xác thực phiên bản:

python -V

Kết quả lệnh

Python 3.9.12

Bây giờ, bạn có thể cài đặt các yêu cầu cho Loourc.

pip3 install -r requirements.txt

Kết quả 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 locus mới cài đặt:

PATH=~/.local/bin":$PATH"
which locust

Kết quả lệnh

/home/<user>/.local/bin/locust

Tóm tắt

Ở bước này, bạn đã thiết lập dự án của mình nếu bạn chưa có dự án, đã kích hoạt Cloud shell và tải mã cho phòng thí nghiệm này xuống.

Cuối cùng, bạn thiết lập Lo Loc đến để tạo nội dung tải vào lúc khác trong phòng thí nghiệm này.

Tiếp theo

Tiếp theo, bạn sẽ thiết lập thực thể 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 thực thể Spanner

Ở bước này, chúng ta đã thiết lập Thực thể Spanner cho lớp học lập trình này. Tìm kiếm mục nhập Spanner 1a6580bd3d3e6783.pngStrong Trình đơn Hamburger ở trên cùng bên trái 3129589f7bc9e5ce.png. hoặc tìm kiếm Spanner bằng cách nhấn "/" và nhập "Spanner"

36e52f8df8e13b99.pngS

Tiếp theo, hãy nhấp vào 95269e75bc8c3e4d.png. 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 như us-central1) rồi đặt số nút. Đối với lớp học lập trình này, chúng ta chỉ cần có 500 processing units.

Cuối cùng nhưng không kém phần quan trọng, hãy nhấp vào "Tạo" và trong vòng vài giây, bạn đã có thể tuỳ ý sử dụng một thực thể Cloud Spanner.

4457c324c94f93e6.pngs

Tạo cơ sở dữ liệu và giản đồ

Khi thực thể của bạn đang chạy, bạn có thể tạo cơ sở dữ liệu. Spanner cho phép nhiều cơ sở dữ liệu trên một thực thể.

Cơ sở dữ liệu là nơi bạn xác định giản đồ của mình. Bạn cũng có thể kiểm soát ai có quyền truy cập vào cơ sở dữ liệu, thiết lập 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 nhiều khu vực, bạn cũng có thể định cấu hình biến thể dẫn đầu mặc định. Đọc thêm về cơ sở dữ liệu trên Spanner.

Đối với lớp học lập trình này, bạn sẽ tạo cơ sở dữ liệu với các tuỳ 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 hai bảng: người chơitrò chơi.

77651ac12e47fe2a.pngS

Người chơi có thể tham gia nhiều trò chơi theo thời gian, nhưng chỉ chơi được một trò tại một thời điểm. Người chơi cũng có stats làm loại dữ liệu JSON để theo dõi các số liệu thống kê thú vị như games_playedgames_won. Do các số liệu thống kê khác có thể được thêm vào sau đó, đây thực sự là một cột không có giản đồ cho người chơi.

Trò chơi theo dõi người chơi đã tham gia bằng cách sử dụng loại dữ liệu ARRAY của Spanner. Thuộc tính người chiến thắng và kết thúc của một trò chơi không được điền cho đến khi trò chơi 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 "Create Database" (Tạo cơ sở dữ liệu) trong phần tổng quan về thực thể:

a820db6c4a4d6f2d.png

Sau đó, điền thông tin chi tiết. Các tuỳ chọn quan trọng là tên cơ sở dữ liệu và phương ngữ. Trong ví dụ này, chúng tôi đã đặt tên cho cơ sở dữ liệu là trò chơi mẫu 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 đó, 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:

d39d358dc7d32939.png

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

f6f98848d3aea9c.png

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 thực thể Spanner và cơ sở dữ liệu trò chơi mẫu. Bạn cũng đã xác định được 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 và tận dụng khung gin.

4fce45ee6c858b3e.png.

Trong API này, người chơi có thể đăng ký chơi trò chơi. Lệnh này được tạo bằng một lệnh POST đơn giản. Lệnh này 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, còn player_name được dùng để hiển thị của trò chơi.

API này hiện không xử lý hoạt động đăng nhập, nhưng bạn có thể triển khai việc này dưới dạng một bài tập bổ sung.

Tệp ./src/golang/profile-service/main.go cho dịch vụ cấu hình hiển thị hai đ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())
}

Mã cho các điểm cuối đó sẽ được gửi đến mô hình người chơi.

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 thực hiện là thiết lập kết nối Spanner. Việ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()
   }
}

Cấu trúc Player (Trình phát) 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 trình phát sẽ tận dụng phương thức chèn DML bên trong giao dịch ReadWrite, vì việc thêm trình phát là một câu lệnh duy nhất thay vì chèn hàng loạt. Hàm 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 người chơi dựa trên mã nhận dạng duy nhất (UUID) của họ, hệ thống sẽ thực hiện một thao tác đọc đơn giản. Thao tác này sẽ truy xuất playerUUID, Player_name, emailstats 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ụ được định cấu hình bằng các biến môi trường. Xem phần có liên quan của 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 hành vi mặc định là chạy dịch vụ trên localhost:8080.

Với thông tin này, đã đến lúc chạy dịch vụ.

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

Kết quả 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 tạo 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"}'

Kết quả 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

Ở 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. Đồng thời, bạn đã thử nghiệm dịch vụ bằng cách thực hiện 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ụ tìm kiếm người chơi.

5. Triển khai dịch vụ tìm kiếm người chơi

Tổng quan về dịch vụ

Dịch vụ tìm kiếm người chơi là một API REST được viết bằng Go, có sử dụng khung gin.

9aecd571df0dcd7c.png.

Trong API này, trò chơi được tạođó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 vào trò chơi đó.

Khi trận đấu đóng, người chiến thắng sẽ được chọn ngẫu nhiên và số liệu thống kê cho games_playedgames_won sẽ được điều chỉnh. Ngoài ra, mỗi người chơi được cập nhật để thông báo rằng họ không còn chơi nữa và sẽ có thể chơi các trò chơi trong tương lai.

Tệp ./src/golang/matchmaking-service/main.go cho dịch vụ mai mối tuân theo cách thiết lập và mã tương tự như dịch vụ ./src/golang/matchmaking-service/main.go nên sẽ không lặp lại ở đây. Dịch vụ này hiển thị hai đ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 cấu trúc Game (Trò chơi), cũng như thu gọn các cấu trúc Player (Trình phát) và 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"`
}

Để tạo trò chơi, dịch vụ mai mối sẽ lấy ngẫu nhiên 100 người chơi hiện không chơi trò chơi nào.

Đột biến Spanner được chọn để tạo trò chơi và chỉ định người chơi, vì đột biến có hiệu suất cao hơn DML đối với những 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 lựa chọn người chơi ngẫu nhiên được thực hiện bằng SQL thông qua tính năng TABLESPACE RESERVOIR của GoogleSQL.

Việc đóng trò chơi sẽ phức tạp hơn một chút. Quá trình 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 trò chơi kết thúc và cập nhật thông tin của từng người chơi số liệu thống kê cho games_playedgames_won.

Do sự phức tạp này và số lượng thay đổi lớn, các đột biến một lần nữa đượ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 dịch vụ hồ sơ, theo mặc định, dịch vụ này chạy trên localhost:8081.

Với thông tin này, đã đến lúc chạy dịch vụ tìm kiếm người chơi.

Chạy dịch vụ tìm kiếm người chơ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 với dịch vụ hồ sơ, vì vậy, 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 . &

Kết quả 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

Kiểm thử dịch vụ để tạo trò chơi. Trước tiên, hãy mở một cửa sổ dòng lệnh mới trong Cloud Shell:

90eceac76a6bb90b.pngS

Sau đó, phát lệnh curl sau:

curl http://localhost:8081/games/create \
    --include \
    --header "Content-Type: application/json" \
    --request "POST"

Kết quả 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"

Kết quả 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 vào 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 ra một người chiến thắng ngẫu nhiên và cập nhật tất cả người chơi trong trò chơi số liệu thống kê cho games_playedgames_won.

Các bước tiếp theo

Hiện các dịch vụ của bạn đang hoạt động, đã đến lúc thu hút người chơi đăng ký và chơi trò chơi!

6. Bắt đầu phát

Giờ đây, khi dịch vụ hồ sơ và mai mối đang chạy, bạn có thể tạo tải bằng cách sử dụng máy tạo cào cào được cung cấp.

Locus cung cấp giao diện web để chạy trình tạo, nhưng trong phòng thí nghiệm này, bạn sẽ sử dụng dòng lệnh (tuỳ chọn –không có giao diện người dùng).

Đăng ký người chơi

Trước tiên, bạn cần tạo người chơi.

Mã python để tạo trình phát 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.

Người chơi đã đăng ký thành công sẽ được truy xuất trong nhiệm vụ thứ hai để tạo lượt 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. Tệp này sẽ tạo trình phát mới trong 30 giây (t=30s) với chế độ đồng thời hai luồng mỗi lần (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

Sau khi bạn đã đăng ký người chơi, họ muốn bắt đầu chơi trò chơi!

Mã python để tạo và đóng 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 được chạy, nó sẽ mở và đóng 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 sẽ 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 nội dung mô phỏng này tận dụng khung Locus Python để đưa ra yêu cầu cho các dịch vụ của chúng tôi API REST.

Bạn có thể chỉnh sửa thời gian tạo người chơi và chơi trò chơi, cũng như số người dùng đồng thời (-u).

Các bước tiếp theo

Sau khi mô phỏng, bạn nên kiểm tra các số liệu thống kê khác nhau bằng cách truy vấn Spanner.

7. Truy xuất số liệu thống kê về trò chơi

Hiện tại, chúng tôi đã mô phỏng người chơi 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 sử dụng Cloud Console để gửi yêu cầu truy vấn cho Spanner.

b5e3154c6f7cb0cf.png

Kiểm tra trò chơi mở so với trò chơi khép kín

Trò chơi đóng là trò chơi có dấu thời gian đã kết thúc được điền sẵn, còn trò chơi đang mở sẽ có kết thúc là trò chơi NULL. Giá trị này được đặt khi trò chơi đóng.

Vì vậy, với truy vấn này, bạn sẽ kiểm tra xem có bao nhiêu trò chơi đang mở và bao nhiêu 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ả:

Type

NumGames

Open Games

0

Closed Games

175

Kiểm tra số lượng người chơi đang chơi và không chơi

Một người chơi đang chơi một trò chơi nếu cột current_game được đặt. Nếu không, họ hiện không chơi trò chơi nào.

Vì vậy, để so sánh số lượng người chơi hiện đang chơi và không chơi, hãy sử dụng truy vấn sau:

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

Type

NumPlayers

Playing

0

Not Playing

310

Xác định những người chiến thắng hàng đầu

Khi trận đấu kết thúc, một trong những người chơi được chọn ngẫu nhiên là người chiến thắng. Số liệu thống kê games_won của người chơi đó tăng lên trong 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

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

09b72595-40af-4406-a000-2fb56c58fe92

{&quot;games_played&quot;:56,&quot;games_won&quot;:1}

1002385b-02a0-462b-a8e7-05c9b27223aa

{&quot;games_played&quot;:66,&quot;games_won&quot;:1}

13ec3770-7ae3-495f-9b53-6322d8e8d6c3

{&quot;games_played&quot;:44,&quot;games_won&quot;:1}

15513852-3f2a-494f-b437-fe7125d15f1b

{&quot;games_played&quot;:49,&quot;games_won&quot;:1}

17faec64-4f77-475c-8df8-6ab026cf6698

{&quot;games_played&quot;:50,&quot;games_won&quot;:1}

1abfcb27-037d-446d-bb7a-b5cd17b5733d

{&quot;games_played&quot;:63,&quot;games_won&quot;:1}

2109a33e-88bd-4e74-a35c-a7914d9e3bde

{&quot;games_played&quot;:56,&quot;games_won&quot;:2}

222e37d9-06b0-4674-865d-a0e5fb80121e

{&quot;games_played&quot;:60,&quot;games_won&quot;:1}

22ced15c-0da6-4fd9-8cb2-1ffd233b3c56

{&quot;games_played&quot;:50,&quot;games_won&quot;: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á thực thể "cloudspanner-gaming" mà chúng ta đã tạo ở bước của lớp học lập trình có tên là "Thiết lập 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ủ đề khi làm việc với Spanner bằng trình điều khiển golang. Khoá học này sẽ giúp bạn có được nền tảng tốt hơn để hiểu các khái niệm quan trọng như:

  • Thiết kế giản đồ
  • DML so với đột biến
  • Làm việc với Golang

Hãy nhớ tham khảo 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!