こんにちは!フルサポート開発チームの高畑(@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 つのサーバにアクセスが集中してしまう現象(ホットスポット)が生じてしまいます。
そのため、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 にぽろっと気持ちがこぼれてしまいましたが、やはり使ったことのないサービスやツールに触れてみると面白いですね ;)