VisasQ Dev Blog

ビザスク開発ブログ

Golang で Cloud Spanner に接続してデータを読み書きしてみた

f:id:visasq_developers:20220412143315p:plain

こんにちは!フルサポート開発チームの高畑(@int_sorarinu)です。

最近になってようやくロボット掃除機を導入してみたのですが、引くほどゴミが取れていて愕然としたとともに、ロボット掃除機の優秀さに驚いている日々を送っている今日この頃、皆様いかがお過ごしでしょうか。

さて今回は、Golang を使って GCP の Cloud Spanner に接続してデータを取得してみたので備忘録がてらご紹介します。

Cloud Spanner とは

GCP で利用できるスケーラブル・強整合性・高可用性(マルチリージョン構成で 99.999%)を兼ね備えたフルマネージドな RDB です。

自分よりも GCP の方が Cloud Spanner について詳しいはずなので、詳細は公式サイトを見てみてください :)

https://cloud.google.com/spanner?hl=ja

検証環境

Cloud Spanner に接続してみる

Cloud Spanner 自体は 2017 年から GCP 上で利用できるようになったのですが、まだ Golang で利用できる ORM も少なく勉強も兼ねて Google Cloud Client ライブラリを利用して実装しています。

package model

import (
    "context"
    "fmt"
    "os"

    "cloud.google.com/go/spanner"
)

func NewSpannerClient(ctx context.Context) (*spanner.Client, error) {
    p := os.Getenv("SPANNER_PROJECT_ID")
    i := os.Getenv("SPANNER_INSTANCE_ID")
    d := os.Getenv("SPANNER_DATABASE_ID")

    c, err := spanner.NewClient(ctx, fmt.Sprintf("projects/%s/instances/%s/databases/%s", p, i, d))
    if err != nil {
        return nil, err
    }

    return c, nil
}

Cloud Spanner の接続情報は MySQL とは異なり、ホスト情報を持たない projects/<GCP Project ID>/instances/<Spanner Instance ID>/databases/<Spanner Database ID>といったフォーマットなので、環境変数でそれぞれの情報を渡して spanner.NewClient()に投げています。

Cloud Spanner のエミュレータを利用している場合、 SPANNER_EMULATOR_HOST環境変数にセットすることで実行されているエミュレータに対し接続がされます。

https://cloud.google.com/spanner/docs/emulator?hl=ja#client-libraries

Cloud Spanner にデータを登録してみる

Cloud Spanner に接続が出来たので、次にデータを投入してみます。

データベースのスキーマは下記の通りです。

CREATE TABLE User (
  ID STRING(36) NOT NULL,
  FirstName STRING(255) NOT NULL,
  LastName STRING(255) NOT NULL,
  Age INT64 NOT NULL,
  CreatedAt TIMESTAMP NOT NULL OPTIONS (
    allow_commit_timestamp = true
  ),
  UpdatedAt TIMESTAMP NOT NULL OPTIONS (
    allow_commit_timestamp = true
  ),
) PRIMARY KEY(ID);

Cloud Spanner は PK の範囲によってデータを物理的に分割(スプリット)して各サーバへ配置します。その際に PK が単調に増加する値(MySQL でいう Auto Increment な値)だと新しいレコードが必然的に最後のスプリットに追加されてしまい、1 つのサーバにアクセスが集中してしまう現象(ホットスポット)が生じてしまいます。

f:id:visasq_developers:20220412143103p:plain

そのため、UUIDv4 を PK として利用することで PK に連続性が生じなくなるため、スプリットが各サーバへ分散されホットスポットを回避することができるようになります。

package model

import (
    "context"
    "time"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"

    "cloud.google.com/go/spanner"
)

type User struct {
    ID        string    `spanner:"ID" json:"id"`
    FirstName string    `spanner:"FirstName" json:"first_name"`
    LastName  string    `spanner:"LastName" json:"last_name"`
    Age       int64     `spanner:"Age" json:"age"`
    CreatedAt time.Time `spanner:"CreatedAt" json:"created_at"`
    UpdatedAt time.Time `spanner:"UpdatedAt" json:"updated_at"`
}

func Insert(ctx *gin.Context, users []User) error {
    c, err := NewSpannerClient(ctx)
    if err != nil {
        return err
    }

    defer c.Close()

    _, err = c.ReadWriteTransaction(ctx, func(ctx context.Context, txn *spanner.ReadWriteTransaction) error {
        var m []*spanner.Mutation

        for _, u := range users {
            u.ID = uuid.New().String()
            u.CreatedAt = spanner.CommitTimestamp
            u.UpdatedAt = spanner.CommitTimestamp

            s, err := spanner.InsertStruct("User", &u)
            if err != nil {
                return err
            }

            m = append(m, s)
        }

        return txn.BufferWrite(m)
    })
    if err != nil {
        return err
    }

    return nil
}

構造体に対して spanner:"hoge"といったメタ情報を付与することによって、Spanner のカラムに対する紐付けができ、spanner.InsertStruct()へ構造体を渡すだけでよしなにデータを登録してくれるようになります。また、spanner.Insert()を利用するとでカラムを直接指定してデータを登録することもできます。

Model の Insert 部分が用意できたら、あとは User の Slice を先程の Insert()に渡してあげることで Spanner にデータが登録されるようになります。

package main

import (
    "net/http"
    "log"

    "test-app/model"

    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()

    v1 := root.Group("/v1")
    {
        v1.POST("/users", func(c *gin.Context) {
            users := []model.User{
                {
                    FirstName: "太郎",
                    LastName:  "テスト",
                    Age:       26,
                },
                {
                    FirstName: "テス子",
                    LastName:  "佐藤",
                    Age:       20,
                },
            }

            err := model.Insert(c, users)
            if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
            }

            c.JSON(http.StatusNoContent, nil)
        })
    }

    if err := r.Run(":8080"); err != nil {
        log.Fatal(err.Error())
    }
}

http://localhost:8080/v1/usersへ POST すると以下のようにデータが登録されました。

spanner> select * from User;
+--------------------------------------+-----------+----------+-----+-----------------------------+-----------------------------+
| ID                                   | FirstName | LastName | Age | CreatedAt                   | UpdatedAt                   |
+--------------------------------------+-----------+----------+-----+-----------------------------+-----------------------------+
| e4d0ae76-e87b-4699-ab2d-d48319fc1205 | 太郎      | テスト   | 26  | 2022-04-04T02:03:57.201933Z | 2022-04-04T02:03:57.201933Z |
| 03fe9c9b-66ac-4ffd-9264-3606a6d64012 | テス子    | 佐藤     | 20  | 2022-04-04T02:03:57.201933Z | 2022-04-04T02:03:57.201933Z |
+--------------------------------------+-----------+----------+-----+-----------------------------+-----------------------------+
2 rows in set (1.098822ms)

Cloud Spanner からデータを取得してみる

データの登録ができたので、次に登録したデータの取得を行ってみます。

今回は単一の検索クエリを発行するため、明示的にReadOnlyTransaction()を実行しなくても読み取り専用のスナップショットトランザクションを返してくれる Single()を利用して SQL を発行しました。

Single().Query()SQL 投げてを実行するとイテレータを返してくれるので、あとは ToStruct()Golang の構造体に対してバインドするだけでユーザの一覧が取得できます(簡単!)。

func GetUsers(ctx *gin.Context) ([]User, error) {
    c, err := NewSpannerClient(ctx)
    if err != nil {
        return nil, err
    }

    defer c.Close()

    iter := c.Single().Query(ctx, spanner.Statement{
        SQL:    `SELECT * FROM User`,
        Params: nil,
    })
    defer iter.Stop()

    users := []User{}

    for {
        row, err := iter.Next()
        if err == iterator.Done {
            break
        }

        if err != nil {
            return nil, err
        }

        var u User
        if err := row.ToStruct(&u); err != nil {
            return nil, err
        }

        users = append(users, u)
    }

    return users, nil
}

ここまでできたらあとは Insertのときと同じく GetUsers()を呼び出してあげることで、 http://localhost:8080/v1/users に GET リクエストを送るとユーザ一覧のレスポンスが取得できるようになります。

func main() {
    :
    :
    v1 := root.Group("/v1")
    {
        :
        :
        v1.GET("/users", func(c *gin.Context) {
            users, err := model.GetUsers(c)
            if err != nil {
                c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
            }

            c.JSON(http.StatusOK, gin.H{"users": users})
        })
    }
    :
    :
}
$ curl -X 'GET' 'http://localhost:8080/v1/users' | jq .
{
  "users": [
    {
      "id": "e4d0ae76-e87b-4699-ab2d-d48319fc1205",
      "first_name": "太郎",
      "last_name": "テスト",
      "age": 26,
      "created_at": "2022-04-04T02:03:57.201933Z",
      "updated_at": "2022-04-04T02:03:57.201933Z"
    },
    {
      "id": "03fe9c9b-66ac-4ffd-9264-3606a6d64012",
      "first_name": "テス子",
      "last_name": "佐藤",
      "age": 20,
      "created_at": "2022-04-04T02:03:57.201933Z",
      "updated_at": "2022-04-04T02:03:57.201933Z"
    }
  ]
}

おわりに

いかがだったでしょうか!

自分自身、Cloud Spanner を利用することが初めてだったのでドキュメントとにらめっこしながら実装しつつ Twitter にぽろっと気持ちがこぼれてしまいましたが、やはり使ったことのないサービスやツールに触れてみると面白いですね ;)