Cloud Spanner: Go로 게임 리더보드 만들기

Google Cloud Spanner는 성능 및 고가용성을 희생하지 않고도 ACID 트랜잭션 및 SQL 시맨틱스를 제공하는 수평 확장이 가능하고, 전역으로 분산되었고, 완전히 관리되는 관계형 데이터베이스 서비스입니다.

이 실습에서는 Cloud Spanner 인스턴스를 설정하는 방법을 알아봅니다. 게임 리더보드에 사용할 수 있는 데이터베이스 및 스키마를 만드는 단계를 진행합니다. 먼저 플레이어 정보를 저장하기 위한 플레이어 테이블을 만들고 플레이어 점수를 저장하기 위한 점수 테이블을 만듭니다.

그런 후 테이블에 샘플 데이터를 채웁니다. 그러고 나서 몇 가지 상위 10개 샘플 쿼리를 실행하고 마지막으로 리소스를 비우기 위해 인스턴스를 삭제함으로써 이 실습을 마칩니다.

학습 내용

  • Cloud Spanner 인스턴스 설정 방법
  • 데이터베이스 및 테이블을 만드는 방법
  • 커밋 타임스탬프 열 사용 방법
  • 타임스탬프가 있는 Cloud Spanner 데이터베이스 테이블에 데이터를 로드하는 방법
  • Cloud Spanner 데이터베이스를 쿼리하는 방법
  • Cloud Spanner 인스턴스를 삭제하는 방법

준비물

본 가이드를 어떻게 사용하실 계획인가요?

읽기만 할 계획입니다. 읽은 다음 연습 활동을 완료할 계획입니다.

귀하의 Google Cloud Platform 사용 경험을 평가해 주세요.

초급 중급 고급

자습형 환경 설정

아직 Google 계정(Gmail 또는 Google Apps)이 없으면 계정을 만들어야 합니다. Google Cloud Platform Console(console.cloud.google.com)에 로그인하고 새 프로젝트를 만듭니다.

프로젝트가 이미 있으면 Console 왼쪽 위에서 프로젝트 선택 풀다운 메뉴를 클릭합니다.

6c9406d9b014760.png

그리고 표시된 대화상자에서 '새 프로젝트' 버튼을 클릭하여 새 프로젝트를 만듭니다.

f708315ae07353d0.png

아직 프로젝트가 없으면 첫 번째 프로젝트를 만들기 위해 다음과 비슷한 대화상자가 표시됩니다.

870a3cbd6541ee86.png

이후의 프로젝트 만들기 대화상자에서 새 프로젝트의 세부정보를 입력할 수 있습니다.

6a92c57d3250a4b3.png

모든 Google Cloud 프로젝트에서 고유한 이름인 프로젝트 ID를 기억하세요(위의 이름은 이미 사용되었으므로 사용할 수 없습니다). 이 ID는 나중에 이 Codelab에서 PROJECT_ID라고 부릅니다.

그런 다음 Google Cloud 리소스를 사용하고 Cloud Spanner API를 사용 설정하기 위해서는 아직 완료하지 않은 경우 Developers Console에서 결제를 사용 설정해야 합니다.

15d0ef27a8fbab27.png

이 codelab을 실행하는 과정에는 많은 비용이 들지 않지만 더 많은 리소스를 사용하려고 하거나 실행 중일 경우 비용이 더 들 수 있습니다(이 문서 마지막의 '삭제' 섹션 참조). Google Cloud Spanner 가격 책정은 여기를 참조하세요.

Google Cloud Platform 신규 사용자는 $300 상당의 무료 체험판을 사용할 수 있으므로, 이 Codelab을 완전히 무료로 사용할 수 있습니다.

Google Cloud Shell 설정

Google Cloud 및 Spanner를 노트북에서 원격으로 실행할 수 있지만, 이 Codelab에서는 Cloud에서 실행되는 명령줄 환경인 Google Cloud Shell을 사용합니다.

이 Debian 기반 가상 머신에는 필요한 모든 개발 도구가 로드되어 있습니다. 영구적인 5GB 홈 디렉터리를 제공하고 Google Cloud에서 실행되므로 네트워크 성능과 인증이 크게 개선됩니다. 즉, 이 Codelab에 필요한 것은 브라우저뿐입니다(Chromebook에서도 작동 가능).

  1. Cloud Console에서 Cloud Shell을 활성화하려면 단순히 Cloud Shell 활성화gcLMt5IuEcJJNnMId-Bcz3sxCd0rZn7IzT_r95C8UZeqML68Y1efBG_B0VRp7hc7qiZTLAF-TXD7SsOadxn8uadgHhaLeASnVS3ZHK39eOlKJOgj9SJua_oeGhMxRrbOg3qigddS2A를 클릭합니다. 환경을 프로비저닝하고 연결하는 데 몇 정도만 소요됩니다.

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

Screen Shot 2017-06-14 at 10.13.43 PM.png

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를 찾고 계신가요? 설정 단계에서 사용한 ID를 확인하거나 Cloud Console 대시보드에서 확인하세요.

158fNPfwSxsFqz9YbtJVZes8viTS3d1bV4CVhij3XPxuzVFOtTObnwsphlm6lYGmgdMFwBJtc-FaLrZU7XHAg_ZYoCrgombMRR3h-eolLPcvO351c5iBv506B3ZwghZoiRg6cz23Qw

또한 Cloud Shell은 기본적으로 이후 명령어를 실행할 때 유용할 수 있는 몇 가지 환경 변수를 설정합니다.

echo $GOOGLE_CLOUD_PROJECT

명령어 결과

<PROJECT_ID>
  1. 마지막으로 기본 영역 및 프로젝트 구성을 설정합니다.
gcloud config set compute/zone us-central1-f

다양한 영역을 선택할 수 있습니다. 자세한 내용은 리전 및 영역을 참조하세요.

요약

이 단계에서는 환경을 설정합니다.

다음 항목

이제 Cloud Spanner 인스턴스를 설정합니다.

이 단계에서는 이 Codelab을 위해 Cloud Spanner 인스턴스를 설정합니다. 왼쪽 위에 있는 햄버거 메뉴 3129589f7bc9e5ce.png에서 Spanner 항목 1a6580bd3d3e6783.png을 검색하거나 '/'를 누르고 'Spanner'를 입력하여 Spanner를 검색합니다.

36e52f8df8e13b99.png

그런 후 95269e75bc8c3e4d.png를 클릭하고 해당 인스턴스 이름에 cloudspanner-leaderboard를 입력하고, 구성을 선택(리전 인스턴스 선택)하여 양식을 작성하고, 노드 수를 설정합니다. 이 Codelab에서는 1개 노드만 필요합니다. 프로덕션 인스턴스의 경우 그리고 Cloud Spanner SLA를 받기 위해서는 Cloud Spanner 인스턴스에서 노드를 3개 이상 실행해야 합니다.

마지막으로 '만들기'를 클릭하면 몇 초 지나지 않아 Cloud Spanner 인스턴스가 준비됩니다.

dceb68e9ed3801e8.png

다음 단계에서는 Go 클라이언트 라이브러리를 사용하여 새 인스턴스에 데이터베이스 및 스키마를 만듭니다.

이 단계에서는 샘플 데이터베이스 및 스키마를 만듭니다.

Go 클라이언트 라이브러리를 사용하여 테이블 2개를 만듭니다. 하나는 플레이어 정보를 위한 플레이어 테이블이고, 다른 하나는 플레이어 점수를 저장하기 위한 점수 테이블입니다. 이렇게 하기 위해 Cloud Shell에서 Go 콘솔 애플리케이션을 만드는 단계를 수행합니다.

먼저 Cloud Shell에 다음 명령어를 입력하여 GitHub에서 이 Codelab의 샘플 코드를 클론합니다.

go get -u github.com/GoogleCloudPlatform/golang-samples/spanner/...

그런 후 애플리케이션을 만들려는 'leaderboard' 디렉터리로 변경합니다.

cd gopath/src/github.com/GoogleCloudPlatform/golang-samples/spanner/spanner_leaderboard

이 Codelab에 필요한 모든 코드는 이 Codelab을 진행할 때 참조할 수 있도록 기존 golang-samples/spanner/spanner_leaderboard/ 디렉터리에 leaderboard라는 실행 가능한 Go 애플리케이션으로 제공되어 있습니다. 여기에서는 새 디렉터리를 만들고 각 단계에서 Leaderboard 애플리케이션의 복사본을 빌드합니다.

애플리케이션에 대해 'codelab'이라는 새 디렉터리를 만들고 다음 명령어를 사용하여 여기로 디렉터리를 변경합니다.

mkdir codelab && cd $_

이제 Spanner 클라이언트 라이브러리를 사용하여 2개 테이블 즉, Players 및 Scores로 구성된 리더보드를 만드는 'Leaderboard'라는 기본적인 Go 애플리케이션을 만들도록 업데이트합니다. 이 작업은 Cloud Shell 편집기에서 바로 수행할 수 있습니다.

아래에서 강조표시된 아이콘을 클릭하여 Cloud Shell 편집기를 엽니다.

73cf70e05f653ca.png

~/gopath/src/github.com/GoogleCloudPlatform/golang-samples/spanner/codelab 폴더에 'leaderboard.go'라는 파일을 만듭니다.

  • 먼저 Cloud Shell 편집기의 폴더 목록에서 'codelab' 폴더를 선택했는지 확인합니다.
  • 그런 후 Cloud Shell 편집기의 '파일' 메뉴에서 '새 파일'을 선택합니다.
  • 새 파일 이름으로 'leaderboard.go'를 입력합니다.

이것은 애플리케이션 코드 및 종속 항목을 포함하기 위한 참조가 포함된 애플리케이션의 기본 파일입니다.

leaderboard 데이터베이스와 PlayersScores 테이블을 만들기 위해 다음 Go 코드를 leaderboard.go 파일에 복사(Ctrl + P)하고 붙여넣습니다(Ctrl + V).

package main

import (
        "context"
        "flag"
        "fmt"
        "io"
        "log"
        "os"
        "regexp"
        "time"

        "cloud.google.com/go/spanner"
        database "cloud.google.com/go/spanner/admin/database/apiv1"

        adminpb "google.golang.org/genproto/googleapis/spanner/admin/database/v1"
)

type adminCommand func(ctx context.Context, w io.Writer, adminClient *database.DatabaseAdminClient, database string) error

func createDatabase(ctx context.Context, w io.Writer, adminClient *database.DatabaseAdminClient, db string) error {
        matches := regexp.MustCompile("^(.*)/databases/(.*)$").FindStringSubmatch(db)
        if matches == nil || len(matches) != 3 {
                return fmt.Errorf("Invalid database id %s", db)
        }
        op, err := adminClient.CreateDatabase(ctx, &adminpb.CreateDatabaseRequest{
                Parent:          matches[1],
                CreateStatement: "CREATE DATABASE `" + matches[2] + "`",
                ExtraStatements: []string{
                        `CREATE TABLE Players(
                            PlayerId INT64 NOT NULL,
                            PlayerName STRING(2048) NOT NULL
                        ) PRIMARY KEY(PlayerId)`,
                        `CREATE TABLE Scores(
                            PlayerId INT64 NOT NULL,
                            Score INT64 NOT NULL,
                            Timestamp TIMESTAMP NOT NULL
                            OPTIONS(allow_commit_timestamp=true)
                        ) PRIMARY KEY(PlayerId, Timestamp),
                        INTERLEAVE IN PARENT Players ON DELETE NO ACTION`,
                },
        })
        if err != nil {
                return err
        }
        if _, err := op.Wait(ctx); err != nil {
                return err
        }
        fmt.Fprintf(w, "Created database [%s]\n", db)
        return nil
}

func createClients(ctx context.Context, db string) (*database.DatabaseAdminClient, *spanner.Client) {
        adminClient, err := database.NewDatabaseAdminClient(ctx)
        if err != nil {
                log.Fatal(err)
        }

        dataClient, err := spanner.NewClient(ctx, db)
        if err != nil {
                log.Fatal(err)
        }

        return adminClient, dataClient
}

func run(ctx context.Context, adminClient *database.DatabaseAdminClient, dataClient *spanner.Client, w io.Writer,
        cmd string, db string, timespan int) error {
        // createdatabase command
        if cmd == "createdatabase" {
                err := createDatabase(ctx, w, adminClient, db)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }
        return nil
}

func main() {
        flag.Usage = func() {
                fmt.Fprintf(os.Stderr, `Usage: leaderboard <command> <database_name> [command_option]

        Command can be one of: createdatabase

Examples:
        leaderboard createdatabase projects/my-project/instances/my-instance/databases/example-db
                - Create a sample Cloud Spanner database along with sample tables in your project.
`)
        }

        flag.Parse()
        flagCount := len(flag.Args())
        if flagCount != 2 {
                flag.Usage()
                os.Exit(2)
        }

        cmd, db := flag.Arg(0), flag.Arg(1)
        // Set timespan to zero, as it's not currently being used
        var timespan int = 0

        ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
        defer cancel()
        adminClient, dataClient := createClients(ctx, db)
        if err := run(ctx, adminClient, dataClient, os.Stdout, cmd, db, timespan); err != nil {
                os.Exit(1)
        }
}

Cloud Shell 편집기의 '파일' 메뉴에서 '저장'을 선택하여 변경사항을 leaderboard.go 파일에 저장합니다.

golang-samples/spanner/spanner_leaderboard 디렉터리에서 leaderboard.go 파일을 사용하여 createdatabase 명령어를 사용 설정하기 위해 코드를 추가한 후 leaderboard.go 파일에 필요한 결과 예시를 확인할 수 있습니다.

Cloud Shell에서 앱을 빌드하려면 leaderboard.go 파일이 있는 codelab 디렉터리에서 'go build'를 실행합니다.

go build leaderboard.go

애플리케이션이 성공적으로 빌드되면 다음 명령어를 입력하여 Cloud Shell에서 결과 애플리케이션을 실행합니다.

./leaderboard

다음과 같은 출력이 표시되어야 합니다.

Usage: leaderboard <command> <database_name> [command_option]

        Command can be one of: createdatabase

Examples:
        leaderboard createdatabase projects/my-project/instances/my-instance/databases/example-db
                - Create a sample Cloud Spanner database along with sample tables in your project.

이 응답에서 현재 사용 가능한 명령어 createdatabase 하나를 포함하는 Leaderboard 애플리케이션을 확인할 수 있습니다. createdatabase 명령어에 필요한 인수가 특정 인스턴스 ID와 데이터베이스 ID를 포함하는 문자열인 것을 알 수 있습니다.

이제 다음 명령어를 실행해 보세요. my-project를 이 Codelab 시작 부분에서 만든 프로젝트 ID로 바꿔야 합니다.

./leaderboard createdatabase projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard

몇 초 후 다음과 같은 응답이 표시됩니다.

Created database [projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard]

Cloud Console의 Cloud Spanner 섹션에서 새 데이터베이스 및 테이블이 왼쪽 측면 메뉴에 표시됩니다.

ba9008bb84cb90b0.png

다음 단계에서 일부 데이터를 새 데이터베이스에 로드하도록 애플리케이션을 업데이트합니다.

이제 leaderboard라는 데이터베이스에 PlayersScores라는 두 테이블이 포함되었습니다. 이제 Go 클라이언트 라이브러리를 사용하여 Players 테이블에 플레이어를 입력하고 Scores 테이블에 각 플레이어의 무작위 점수를 입력합니다.

아직 열려 있지 않으면 아래 강조 표시된 아이콘을 클릭하여 Cloud Shell 편집기를 엽니다.

ef49fcbaaed19024.png

그런 다음 Cloud Shell 편집기에서 leaderboard.go 파일을 수정하여 Players 테이블에 100명의 플레이어를 삽입하기 위해 사용될 수 있는 insertplayers 명령어를 추가합니다. 또한 Players 테이블에 있는 각 플레이어에 대해 Scores 테이블에 4개의 무작위 점수를 삽입하기 위해 사용될 수 있는 insertscores 명령어를 추가합니다.

먼저 leaderboard.go 파일의 상단에서 imports 섹션을 업데이트하고 현재 있는 항목을 바꿔서 다음과 같이 표시되도록 합니다.

import (
        "context"
        "flag"
        "fmt"
        "io"
        "log"
        "math/rand"
        "os"
        "regexp"
        "time"

        "cloud.google.com/go/spanner"
        database "cloud.google.com/go/spanner/admin/database/apiv1"

        "google.golang.org/api/iterator"
        adminpb "google.golang.org/genproto/googleapis/spanner/admin/database/v1"
)

그런 후 파일 상단의 'type adminCommand ...'로 시작하는 줄 바로 아래에 명령어 목록과 함께 새 명령어 유형을 추가하면 다음과 같이 표시됩니다.

type adminCommand func(ctx context.Context, w io.Writer, adminClient *database.DatabaseAdminClient, database string) error

type command func(ctx context.Context, w io.Writer, client *spanner.Client) error
var (
        commands = map[string]command{
                "insertplayers": insertPlayers,
                "insertscores":  insertScores,
        }
)

그런 후 다음 insertPlayers 및 insertScores 함수를 기존 createdatabase() 함수 아래에 추가합니다.

func insertPlayers(ctx context.Context, w io.Writer, client *spanner.Client) error {
        // Get number of players to use as an incrementing value for each PlayerName to be inserted
        stmt := spanner.Statement{
                SQL: `SELECT Count(PlayerId) as PlayerCount FROM Players`,
        }
        iter := client.Single().Query(ctx, stmt)
        defer iter.Stop()
        row, err := iter.Next()
        if err != nil {
                return err
        }
        var numberOfPlayers int64 = 0
        if err := row.Columns(&numberOfPlayers); err != nil {
                return err
        }
        // Intialize values for random PlayerId
        rand.Seed(time.Now().UnixNano())
        min := 1000000000
        max := 9000000000
        // Insert 100 player records into the Players table
        _, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
                stmts := []spanner.Statement{}
                for i := 1; i <= 100; i++ {
                        numberOfPlayers++
                        playerID := rand.Intn(max-min) + min
                        playerName := fmt.Sprintf("Player %d", numberOfPlayers)
                        stmts = append(stmts, spanner.Statement{
                                SQL: `INSERT INTO Players
                                                (PlayerId, PlayerName)
                                                VALUES (@playerID, @playerName)`,
                                Params: map[string]interface{}{
                                        "playerID":   playerID,
                                        "playerName": playerName,
                                },
                        })
                }
                _, err := txn.BatchUpdate(ctx, stmts)
                if err != nil {
                        return err
                }
                return nil
        })
        fmt.Fprintf(w, "Inserted players \n")
        return nil
}

func insertScores(ctx context.Context, w io.Writer, client *spanner.Client) error {
        playerRecordsFound := false
        // Create slice for insert statements
        stmts := []spanner.Statement{}
        // Select all player records
        stmt := spanner.Statement{SQL: `SELECT PlayerId FROM Players`}
        iter := client.Single().Query(ctx, stmt)
        defer iter.Stop()
        // Insert 4 score records into the Scores table for each player in the Players table
        for {
                row, err := iter.Next()
                if err == iterator.Done {
                        break
                }
                if err != nil {
                        return err
                }
                playerRecordsFound = true
                var playerID int64
                if err := row.ColumnByName("PlayerId", &playerID); err != nil {
                        return err
                }
                // Intialize values for random score and date
                rand.Seed(time.Now().UnixNano())
                min := 1000
                max := 1000000
                for i := 0; i < 4; i++ {
                        // Generate random score between 1,000 and 1,000,000
                        score := rand.Intn(max-min) + min
                        // Generate random day within the past two years
                        now := time.Now()
                        endDate := now.Unix()
                        past := now.AddDate(0, -24, 0)
                        startDate := past.Unix()
                        randomDateInSeconds := rand.Int63n(endDate-startDate) + startDate
                        randomDate := time.Unix(randomDateInSeconds, 0)
                        // Add insert statement to stmts slice
                        stmts = append(stmts, spanner.Statement{
                                SQL: `INSERT INTO Scores
                                                (PlayerId, Score, Timestamp)
                                                VALUES (@playerID, @score, @timestamp)`,
                                Params: map[string]interface{}{
                                        "playerID":  playerID,
                                        "score":     score,
                                        "timestamp": randomDate,
                                },
                        })
                }

        }
        if !playerRecordsFound {
                fmt.Fprintln(w, "No player records currently exist. First insert players then insert scores.")
        } else {
                _, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
                        // Commit insert statements for all scores to be inserted as a single transaction
                        _, err := txn.BatchUpdate(ctx, stmts)
                        return err
                })
                if err != nil {
                        return err
                }
                fmt.Fprintln(w, "Inserted scores")
        }
        return nil
}

그런 후 insert 명령어 작동을 위해 createdatabase 처리 문 아래의 애플리케이션의 'run' 함수에 다음 코드를 추가하고 return nil 문을 바꿉니다.

        // insert and query commands
        cmdFn := commands[cmd]
        if cmdFn == nil {
                flag.Usage()
                os.Exit(2)
        }
        err := cmdFn(ctx, w, dataClient)
        if err != nil {
                fmt.Fprintf(w, "%s failed with %v", cmd, err)
        }
        return err

완료되면 run 함수가 다음과 같이 표시됩니다.

func run(ctx context.Context, adminClient *database.DatabaseAdminClient, dataClient *spanner.Client, w io.Writer,
        cmd string, db string, timespan int) error {
        // createdatabase command
        if cmd == "createdatabase" {
                err := createDatabase(ctx, w, adminClient, db)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }

        // insert and query commands
        cmdFn := commands[cmd]
        if cmdFn == nil {
                flag.Usage()
                os.Exit(2)
        }
        err := cmdFn(ctx, w, dataClient)
        if err != nil {
                fmt.Fprintf(w, "%s failed with %v", cmd, err)
        }
        return err
}

애플리케이션에 '삽입' 기능을 추가하기 위한 마지막 단계는 flag.Usage() 함수에 대한 'insertplayers' 및 'insertscores' 명령어에 대해 도움말 텍스트를 추가하는 것입니다. 삽입 명령어에 대해 도움말 텍스트를 포함하도록 flag.Usage() 함수에 다음 도움말 텍스트를 추가합니다.

가능한 명령어 목록에 2개 명령어를 추가합니다.

Command can be one of: createdatabase, insertplayers, insertscores

이 추가 도움말 텍스트를 createdatabase 명령어의 도움말 텍스트 아래에 추가합니다.

        leaderboard insertplayers projects/my-project/instances/my-instance/databases/example-db
                - Insert 100 sample Player records into the database.
        leaderboard insertscores projects/my-project/instances/my-instance/databases/example-db
                - Insert sample score data into Scores sample Cloud Spanner database table.

Cloud Shell 편집기의 '파일' 메뉴에서 '저장'을 선택하여 변경사항을 leaderboard.go 파일에 저장합니다.

golang-samples/spanner/spanner_leaderboard 디렉터리에서 leaderboard.go 파일을 사용하여 insertplayersinsertscores 명령어를 사용 설정하기 위해 코드를 추가한 후 leaderboard.go 파일에 필요한 결과 예시를 확인할 수 있습니다.

이제 애플리케이션을 빌드하고 실행하여 새 insertplayersinsertscores 명령어가 애플리케이션의 사용 가능한 명령어 목록에 포함되었는지 확인합니다. 다음 명령어를 실행하여 애플리케이션을 빌드합니다.

go build leaderboard.go

다음 명령어를 입력하여 Cloud Shell에서 결과 애플리케이션을 실행합니다.

./leaderboard

이제 insertplayersinsertscores 명령어가 애플리케이션의 기본 출력에 포함된 것이 확인됩니다.

Usage: leaderboard <command> <database_name> [command_option]

        Command can be one of: createdatabase, insertplayers, insertscores

Examples:
        leaderboard createdatabase projects/my-project/instances/my-instance/databases/example-db
                - Create a sample Cloud Spanner database along with sample tables in your project.
        leaderboard insertplayers projects/my-project/instances/my-instance/databases/example-db
                - Insert 100 sample Player records into the database.
        leaderboard insertscores projects/my-project/instances/my-instance/databases/example-db
                - Insert sample score data into Scores sample Cloud Spanner database table.

이제 createdatabase 명령어를 호출할 때 사용한 것과 동일한 인수 값을 사용하여 insertplayers 명령어를 실행합니다. my-project를 이 Codelab 시작 부분에서 만든 프로젝트 ID로 바꿔야 합니다.

./leaderboard insertplayers projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard

몇 초 후 다음과 같은 응답이 표시됩니다.

Inserted players

이제 Go 클라이언트 라이브러리를 사용하여 Players 테이블의 각 플레이어에 대해 타임스탬프와 함께 4개의 무작위 점수를 Scores 테이블에 입력합니다.

Scores 테이블의 Timestamp 열은 이전에 create 명령어를 실행했을 때 수행된 다음 SQL 문을 통해 '커밋 타임스탬프' 열로 정의되었습니다.

CREATE TABLE Scores(
  PlayerId INT64 NOT NULL,
  Score INT64 NOT NULL,
  Timestamp TIMESTAMP NOT NULL OPTIONS(allow_commit_timestamp=true)
) PRIMARY KEY(PlayerId, Timestamp),
    INTERLEAVE IN PARENT Players ON DELETE NO ACTION

OPTIONS(allow_commit_timestamp=true) 속성을 확인합니다. 그러면 Timestamp가 '커밋 타임스탬프' 열로 지정되고 제공된 테이블 행에서 INSERT 및 UPDATE 작업에 대한 정확한 트랜잭션 타임스탬프로 자동으로 입력되도록 만듭니다.

또한 타임스탬프에 과거 시간의 값을 삽입하는 한 고유 타임스탬프 값을 '커밋 타임스탬프' 열에 삽입할 수 있습니다. 이 작업은 이 Codelab에서 수행됩니다.

이제 insertplayers 명령어를 호출할 때 사용한 것과 동일한 인수 값을 사용하여 insertscores 명령어를 실행합니다. my-project를 이 Codelab 시작 부분에서 만든 프로젝트 ID로 바꿔야 합니다.

./leaderboard insertscores projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard

몇 초 후 다음과 같은 응답이 표시됩니다.

Inserted scores

insertScores 함수를 실행하면 다음 코드 스니펫을 사용하여 과거의 날짜/시간으로 무작위로 생성된 타임스탬프를 삽입합니다.

now := time.Now()
endDate := now.Unix()
past := now.AddDate(0, -24, 0)
startDate := past.Unix()
randomDateInSeconds := rand.Int63n(endDate-startDate) + startDate
randomDate := time.Unix(randomDateInSeconds, 0)
stmts = append(stmts, spanner.Statement{
        SQL: `INSERT INTO Scores
              (PlayerId, Score, Timestamp)
                 VALUES (@playerID, @score, @timestamp)`,
        Params: map[string]interface{}{
                "playerID":  playerID,
                "score":     score,
                "timestamp": randomDate,
        },
})

정확히 '삽입' 트랜잭션이 수행될 때의 타임스탬프를 Timestamp 열에 자동으로 입력하려면 다음 코드 스니펫과 같이 Go 상수 spanner.CommitTimestamp를 대신 삽입할 수 있습니다.

...
stmts = append(stmts, spanner.Statement{
        SQL: `INSERT INTO Scores
              (PlayerId, Score, Timestamp)
                 VALUES (@playerID, @score, @timestamp)`,
        Params: map[string]interface{}{
                "playerID":  playerID,
                "score":     score,
                "timestamp": spanner.CommitTimestamp,
        },
})

이제 데이터 로드가 완료되었으므로 Cloud Console의 Cloud Spanner 섹션에서 새 테이블에 기록한 값을 확인합니다. 먼저 leaderboard 데이터베이스를 선택하고 Players 테이블을 선택합니다. Data 탭을 클릭합니다. 테이블의 PlayerIdPlayerName 열에 데이터가 포함된 것을 알 수 있습니다.

7bc2c96293c31c49.png

이제 Scores 테이블을 클릭하고 Data 탭을 선택하여 점수 테이블에도 데이터가 있는지 확인합니다. 테이블의 PlayerId, Timestamp, Score 열에 데이터가 포함된 것을 알 수 있습니다.

d8a4ee4f13244c19.png

잘 하셨습니다. 게임 리더보드를 만들기 위해 사용할 수 있는 몇 가지 쿼리를 실행하기 위해 앱을 업데이트해보겠습니다.

이제 데이터베이스가 설정되었고 테이블에 정보가 로드되었으므로, 이 데이터를 사용하는 리더보드를 만듭니다. 이를 위해서는 다음 4개 질문에 답변해야 합니다.

  1. 항상 '상위 10대' 플레이어는 누구인가요?
  2. 올해 '상위 10대' 플레이어는 누구인가요?
  3. 이달의 '상위 10대' 플레이어는 누구인가요?
  4. 이번 주 '상위 10대' 플레이어는 누구인가요?

이러한 질문에 답변하는 SQL 쿼리를 실행하도록 애플리케이션을 업데이트합니다.

리더보드에 필요한 정보를 생성하는 질문에 답변하도록 쿼리를 실행할 수 있는 방법을 제공하는 query 명령어 및 queryWithTimespan 명령어를 추가합니다.

Cloud Shell 편집기에서 leaderboard.go 파일을 편집하여 query 명령어 및 queryWithTimespan 명령어를 추가하도록 애플리케이션을 업데이트합니다. 또한 점수를 쉼표 형식으로 지정하기 위해 formatWithCommas 도우미 함수를 추가합니다.

먼저 leaderboard.go 파일의 상단에서 imports 섹션을 업데이트하고 현재 있는 항목을 바꿔서 다음과 같이 표시되도록 합니다.

import (
        "bytes"
        "context"
        "flag"
        "fmt"
        "io"
        "log"
        "math/rand"
        "os"
        "regexp"
        "strconv"
        "time"

        "cloud.google.com/go/spanner"
        database "cloud.google.com/go/spanner/admin/database/apiv1"

        "google.golang.org/api/iterator"
        adminpb "google.golang.org/genproto/googleapis/spanner/admin/database/v1"
)

그런 후 다음 두 함수와 도우미 함수를 기존 insertScores 메서드 아래에 추가합니다.

func query(ctx context.Context, w io.Writer, client *spanner.Client) error {
        stmt := spanner.Statement{
                SQL: `SELECT p.PlayerId, p.PlayerName, s.Score, s.Timestamp
                        FROM Players p
                        JOIN Scores s ON p.PlayerId = s.PlayerId
                        ORDER BY s.Score DESC LIMIT 10`}
        iter := client.Single().Query(ctx, stmt)
        defer iter.Stop()
        for {
                row, err := iter.Next()
                if err == iterator.Done {
                        return nil
                }
                if err != nil {
                        return err
                }
                var playerID, score int64
                var playerName string
                var timestamp time.Time
                if err := row.Columns(&playerID, &playerName, &score, &timestamp); err != nil {
                        return err
                }
                fmt.Fprintf(w, "PlayerId: %d  PlayerName: %s  Score: %s  Timestamp: %s\n",
                        playerID, playerName, formatWithCommas(score), timestamp.String()[0:10])
        }
}

func queryWithTimespan(ctx context.Context, w io.Writer, client *spanner.Client, timespan int) error {
        stmt := spanner.Statement{
                SQL: `SELECT p.PlayerId, p.PlayerName, s.Score, s.Timestamp
                                FROM Players p
                                JOIN Scores s ON p.PlayerId = s.PlayerId
                                WHERE s.Timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @Timespan HOUR)
                                ORDER BY s.Score DESC LIMIT 10`,
                Params: map[string]interface{}{"Timespan": timespan},
        }
        iter := client.Single().Query(ctx, stmt)
        defer iter.Stop()
        for {
                row, err := iter.Next()
                if err == iterator.Done {
                        return nil
                }
                if err != nil {
                        return err
                }
                var playerID, score int64
                var playerName string
                var timestamp time.Time
                if err := row.Columns(&playerID, &playerName, &score, &timestamp); err != nil {
                        return err
                }
                fmt.Fprintf(w, "PlayerId: %d  PlayerName: %s  Score: %s  Timestamp: %s\n",
                        playerID, playerName, formatWithCommas(score), timestamp.String()[0:10])
        }
}

func formatWithCommas(n int64) string {
        numberAsString := strconv.FormatInt(n, 10)
        numberLength := len(numberAsString)
        if numberLength < 4 {
                return numberAsString
        }
        var buffer bytes.Buffer
        comma := []rune(",")
        bufferPosition := numberLength % 3
        if (bufferPosition) > 0 {
                bufferPosition = 3 - bufferPosition
        }
        for i := 0; i < numberLength; i++ {
                if bufferPosition == 3 {
                        buffer.WriteRune(comma[0])
                        bufferPosition = 0
                }
                bufferPosition++
                buffer.WriteByte(numberAsString[i])
        }
        return buffer.String()
}

leaderboard.go 파일 상단에서 commands 변수가 다음과 같이 표시되도록 'insertscores": insertScores' 옵션 바로 아래의 commands 변수에 하나의 명령어 옵션으로 'query'를 추가합니다.

var (
        commands = map[string]command{
                "insertplayers": insertPlayers,
                "insertscores":  insertScores,
                "query":         query,
        }
)

그런 후 run 함수 내에서 'createdatabase' 함수 섹션 아래 그리고 'insert 및 query' 명령어 처리 섹션 위에 명령어 옵션으로 'queryWithTimespan'을 추가합니다.

        // querywithtimespan command
        if cmd == "querywithtimespan" {
                err := queryWithTimespan(ctx, w, dataClient, timespan)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }

완료되면 run 함수가 다음과 같이 표시됩니다.

func run(ctx context.Context, adminClient *database.DatabaseAdminClient, dataClient *spanner.Client, w io.Writer,
        cmd string, db string, timespan int) error {
        // createdatabase command
        if cmd == "createdatabase" {
                err := createDatabase(ctx, w, adminClient, db)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }

        // querywithtimespan command
        if cmd == "querywithtimespan" {
                if timespan == 0 {
                        flag.Usage()
                        os.Exit(2)
                }
                err := queryWithTimespan(ctx, w, dataClient, timespan)
                if err != nil {
                        fmt.Fprintf(w, "%s failed with %v", cmd, err)
                }
                return err
        }

        // insert and query commands
        cmdFn := commands[cmd]
        if cmdFn == nil {
                flag.Usage()
                os.Exit(2)
        }
        err := cmdFn(ctx, w, dataClient)
        if err != nil {
                fmt.Fprintf(w, "%s failed with %v", cmd, err)
        }
        return err
}

그런 후 queryWithTimespan 명령어 작동을 위해 다음과 같이 표시되도록 애플리케이션의 'main' 메서드에서 flag.Parse() 코드 블록을 업데이트합니다.

        flag.Parse()
        flagCount := len(flag.Args())
        if flagCount < 2 || flagCount > 3 {
                flag.Usage()
                os.Exit(2)
        }

        cmd, db := flag.Arg(0), flag.Arg(1)
        // If query timespan flag is specified, parse to int
        var timespan int = 0
        if flagCount == 3 {
                parsedTimespan, err := strconv.Atoi(flag.Arg(2))
                if err != nil {
                        fmt.Println(err)
                        os.Exit(2)
                }
                timespan = parsedTimespan
        }

        ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
        defer cancel()
        adminClient, dataClient := createClients(ctx, db)
        if err := run(ctx, adminClient, dataClient, os.Stdout, cmd, db, timespan); err != nil {
                os.Exit(1)
        }

애플리케이션에 'query' 기능을 추가하기 위한 마지막 단계는 'query' 및 'querywithtimespan' 명령어에 대한 도움말 텍스트를 flag.Usage() 함수에 추가하는 것입니다. 쿼리 명령어의 도움말 텍스트를 포함하도록 flag.Usage() 함수에 다음 코드 줄을 추가합니다.

가능한 명령어 목록에 2개의 'query' 명령어를 추가합니다.

Command can be one of: createdatabase, insertplayers, insertscores, query, querywithtimespan

이 추가 도움말 텍스트를 insertscores 명령어의 도움말 텍스트 아래에 추가합니다.

        leaderboard query projects/my-project/instances/my-instance/databases/example-db
                - Query players with top ten scores of all time.
        leaderboard querywithtimespan projects/my-project/instances/my-instance/databases/example-db 168
                - Query players with top ten scores within a timespan specified in hours.

Cloud Shell 편집기의 '파일' 메뉴에서 '저장'을 선택하여 변경사항을 leaderboard.go 파일에 저장합니다.

golang-samples/spanner/spanner_leaderboard 디렉터리에서 leaderboard.go 파일을 사용하여 queryquerywithtimespan 명령어를 사용 설정하기 위해 코드를 추가한 후 leaderboard.go 파일에 필요한 결과 예시를 확인할 수 있습니다.

이제 애플리케이션을 빌드하고 실행하여 새 queryquerywithtimespan 명령어가 애플리케이션의 사용 가능한 명령어 목록에 포함되었는지 확인합니다.

Cloud Shell에서 다음 명령어를 실행하여 애플리케이션을 빌드합니다.

go build leaderboard.go

다음 명령어를 입력하여 Cloud Shell에서 결과 애플리케이션을 실행합니다.

./leaderboard

이제 queryquerywithtimespan 명령어가 앱의 기본 출력에서 새 명령어 옵션으로 포함된 것을 확인할 수 있습니다.

Usage: leaderboard <command> <database_name> [command_option]

        Command can be one of: createdatabase, insertplayers, insertscores, query, querywithtimespan

Examples:
        leaderboard createdatabase projects/my-project/instances/my-instance/databases/example-db
                - Create a sample Cloud Spanner database along with sample tables in your project.
        leaderboard insertplayers projects/my-project/instances/my-instance/databases/example-db
                - Insert 100 sample Player records into the database.
        leaderboard insertscores projects/my-project/instances/my-instance/databases/example-db
                - Insert sample score data into Scores sample Cloud Spanner database table.
        leaderboard query projects/my-project/instances/my-instance/databases/example-db
                - Query players with top ten scores of all time.
        leaderboard querywithtimespan projects/my-project/instances/my-instance/databases/example-db 168
                - Query players with top ten scores within a timespan specified in hours.

응답에서 query 명령어를 사용하여 항상 '상위 10대' 플레이어 목록을 가져올 수 있음을 알 수 있습니다. 또한 querywithtimespan 명령어를 사용하여 Scores 테이블의 Timestamp 열에 있는 값을 기준으로 레코드를 필터링하는 데 사용하기 위해 기간을 시간 단위로 지정할 수 있다는 것을 알 수 있습니다.

create 명령어를 실행할 때 사용한 것과 동일한 인수 값을 사용하여 query 명령어를 실행해보세요. my-project를 이 Codelab 시작 부분에서 만든 프로젝트 ID로 바꿔야 합니다.

./leaderboard query  projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard

다음과 같이 항상 '상위 10대' 플레이어를 포함하는 응답이 표시됩니다.

PlayerId: 4018687297  PlayerName: Player 83  Score: 999,618  Timestamp: 2017-07-01
PlayerId: 4018687297  PlayerName: Player 83  Score: 998,956  Timestamp: 2017-09-02
PlayerId: 4285713246  PlayerName: Player 51  Score: 998,648  Timestamp: 2017-12-01
PlayerId: 5267931774  PlayerName: Player 49  Score: 997,733  Timestamp: 2017-11-09
PlayerId: 1981654448  PlayerName: Player 35  Score: 997,480  Timestamp: 2018-12-06
PlayerId: 4953940705  PlayerName: Player 87  Score: 995,184  Timestamp: 2018-09-14
PlayerId: 2456736905  PlayerName: Player 84  Score: 992,881  Timestamp: 2017-04-14
PlayerId: 8234617611  PlayerName: Player 19  Score: 992,399  Timestamp: 2017-12-27
PlayerId: 1788051688  PlayerName: Player 76  Score: 992,265  Timestamp: 2018-11-22
PlayerId: 7127686505  PlayerName: Player 97  Score: 992,038  Timestamp: 2017-12-02

이제 1년 동안의 시간 수인 8760과 동일한 '기간'을 지정하여 1년 동안 '상위 10대' 플레이어를 쿼리하는 데 필요한 인수를 사용하여 querywithtimespan 명령어를 실행합니다. my-project를 이 Codelab 시작 부분에서 만든 프로젝트 ID로 바꿔야 합니다.

./leaderboard querywithtimespan projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard 8760

다음과 같이 1년 동안 '상위 10대' 플레이어를 포함하는 응답이 표시됩니다.

PlayerId: 1981654448  PlayerName: Player 35  Score: 997,480  Timestamp: 2018-12-06
PlayerId: 4953940705  PlayerName: Player 87  Score: 995,184  Timestamp: 2018-09-14
PlayerId: 1788051688  PlayerName: Player 76  Score: 992,265  Timestamp: 2018-11-22
PlayerId: 6862349579  PlayerName: Player 30  Score: 990,877  Timestamp: 2018-09-14
PlayerId: 5529627211  PlayerName: Player 16  Score: 989,142  Timestamp: 2018-03-30
PlayerId: 9743904155  PlayerName: Player 1  Score: 988,765  Timestamp: 2018-05-30
PlayerId: 6809119884  PlayerName: Player 7  Score: 986,673  Timestamp: 2018-05-16
PlayerId: 2132710638  PlayerName: Player 54  Score: 983,108  Timestamp: 2018-09-11
PlayerId: 2320093590  PlayerName: Player 79  Score: 981,373  Timestamp: 2018-05-07
PlayerId: 9554181430  PlayerName: Player 80  Score: 981,087  Timestamp: 2018-06-21

이제 querywithtimespan 명령어를 실행하고 '기간'을 1개월 동안의 시간 수인 730과 동일하게 지정하여 1개월 동안 '상위 10'대 플레이어를 쿼리합니다. my-project를 이 Codelab 시작 부분에서 만든 프로젝트 ID로 바꿔야 합니다.

./leaderboard querywithtimespan projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard 730

다음과 같이 1개월 동안 '상위 10대' 플레이어를 포함하는 응답이 표시됩니다.

PlayerId: 3869829195  PlayerName: Player 69  Score: 949,686  Timestamp: 2019-02-19
PlayerId: 7448359883  PlayerName: Player 20  Score: 938,998  Timestamp: 2019-02-07
PlayerId: 1981654448  PlayerName: Player 35  Score: 929,003  Timestamp: 2019-02-22
PlayerId: 9336678658  PlayerName: Player 44  Score: 914,106  Timestamp: 2019-01-27
PlayerId: 6968576389  PlayerName: Player 40  Score: 898,041  Timestamp: 2019-02-21
PlayerId: 5529627211  PlayerName: Player 16  Score: 896,433  Timestamp: 2019-01-29
PlayerId: 9395039625  PlayerName: Player 59  Score: 879,495  Timestamp: 2019-02-09
PlayerId: 2094604854  PlayerName: Player 39  Score: 860,434  Timestamp: 2019-02-01
PlayerId: 9395039625  PlayerName: Player 59  Score: 849,955  Timestamp: 2019-02-21
PlayerId: 4285713246  PlayerName: Player 51  Score: 805,654  Timestamp: 2019-02-02

이제 querywithtimespan 명령어를 실행하고 '기간'을 1주일 동안의 시간 수인 168과 동일하게 지정하여 1주일 동안 '상위 10'대 플레이어를 쿼리합니다. my-project를 이 Codelab 시작 부분에서 만든 프로젝트 ID로 바꿔야 합니다.

./leaderboard querywithtimespan  projects/my-project/instances/cloudspanner-leaderboard/databases/leaderboard 168

다음과 같이 1주일 동안 '상위 10대' 플레이어를 포함하는 응답이 표시됩니다.

PlayerId: 3869829195  PlayerName: Player 69  Score: 949,686  Timestamp: 2019-02-19
PlayerId: 1981654448  PlayerName: Player 35  Score: 929,003  Timestamp: 2019-02-22
PlayerId: 6968576389  PlayerName: Player 40  Score: 898,041  Timestamp: 2019-02-21
PlayerId: 9395039625  PlayerName: Player 59  Score: 849,955  Timestamp: 2019-02-21
PlayerId: 5954045812  PlayerName: Player 8  Score: 795,639  Timestamp: 2019-02-22
PlayerId: 3889939638  PlayerName: Player 71  Score: 775,252  Timestamp: 2019-02-21
PlayerId: 5529627211  PlayerName: Player 16  Score: 604,695  Timestamp: 2019-02-19
PlayerId: 9006728426  PlayerName: Player 3  Score: 457,208  Timestamp: 2019-02-22
PlayerId: 8289497066  PlayerName: Player 58  Score: 227,697  Timestamp: 2019-02-20
PlayerId: 8065482904  PlayerName: Player 99  Score: 198,429  Timestamp: 2019-02-24

훌륭합니다!

이제 필요한 레코드가 아무리 커도 레코드를 추가하면 Cloud Spanner가 데이터베이스를 확장합니다. 데이터베이스가 얼마나 커지더라도 Cloud Spanner 및 Truetime 기술을 통해 게임 리더보드가 계속 정확하게 확장될 수 있습니다.

Spanner에 대한 모든 학습이 완료되었으면 귀중한 리소스와 비용을 절약하기 위해 사용된 리소스를 삭제해야 합니다. 이 단계는 매우 간단합니다. Cloud Console의 Cloud Spanner 섹션으로 이동하고 'Cloud Spanner 인스턴스 설정' Codelab 단계에서 만든 인스턴스만 삭제하면 됩니다.

학습한 내용:

  • 리더보드에 대한 Google Cloud Spanner 인스턴스, 데이터베이스, 테이블 스키마
  • Go 콘솔 애플리케이션을 만드는 방법
  • Go 클라이언트 라이브러리를 사용하여 Spanner 데이터베이스 및 테이블을 만드는 방법
  • Go 클라이언트 라이브러리를 사용하여 Spanner 데이터베이스에 데이터를 로드하는 방법
  • Spanner 커밋 타임스탬프 및 Go 클라이언트 라이브러리를 사용하여 데이터에서 '상위 10대' 결과를 쿼리하는 방법

다음 단계:

Google에 의견 보내기

  • 잠시 시간을 내어 간단한 설문조사에 응해주시기 바랍니다.