SpannerでもsqlcでSQLから型安全なコードを生成してみる
この記事はMakuake Advent Calendar 2022の25日目の記事です。
皆さんはCloud Spannerは使っていますか?SpannerはACIDに対応した分散DBで、分類上はNewSQLと呼ばれています(他にはTiDBとか)。また、メンテナンスに際したダウンタイムが発生しない点でも運用コストが低く、社内では一部のサービスでSpannerを採用しています。
先日、ついにdatabase/sql
ドライバーの一般提供がスタートしました!
これまでは独自のクライアントライブラリを使用しなければなりませんでしたが、これでMySQLやpostgreSQLなど他DBと同様に同一のinterfaceを介してDB操作が可能になります。
※ただし、一部制限事項もあります:https://github.com/googleapis/go-sql-spanner/blob/main/docs/limitations.rst
database/sql
に対応したことで、sqlcも使えるんじゃなかろうかというところの検証をやってみたいと思います。
注意点
現状sqlcはZetaSQLは非対応ですが、それをMySQLに変換して(若干強引に)使ってみようという実験的な試みについての記事になります。
よって、productionレベルでの使用は想定していません。
sqlc?
SQLから型安全なコード生成をしてくれるツール&ライブラリです。
- コード上で実行したいSQLクエリを考える
- DBMSとやりとりするためのデータ構造を作成する(modelとか)
- ドライバを介してDBMSにクエリ実行を行う処理を作成する(repositoryとか)
このうち、2と3を自動生成してくれます(3についてはトランザクションをどのレイヤーで管理するかにもよるところはありますが)
詳細はこのあたりの記事が詳しそうです
やってみる
では実際に使ってみます。
なお、最近Spannerの無料トライアルインスタンスが一般提供開始したので、エミュレーターではなく実インスタンスでやってみます。
インスタンスやDB作成は割愛します。
スキーマ定義
簡単な1:nのテーブルと、トランザクション確認用のCounterを作成します。
CREATE TABLE Users (
ID STRING(36) NOT NULL,
Name STRING(255) NOT NULL
) PRIMARY KEY(ID);
CREATE TABLE Addresses (
ID STRING(36) NOT NULL,
UserID STRING(36) NOT NULL,
Address STRING(255) NOT NULL,
CONSTRAINT FK_UserAddress FOREIGN KEY (UserID) REFERENCES Users (ID)
) PRIMARY KEY(ID);
Create Index AddressesByUserID On Addresses(UserID);
CREATE TABLE Counters (
ID STRING(36) NOT NULL,
Count INT64 NOT NULL,
) PRIMARY KEY(ID);
サンプルデータ準備
-- Users
INSERT INTO
Users (ID, Name)
VALUES
('199f8059-558a-4c6f-aad3-526859cfa88e', 'User A'),
('7d586bac-1c9e-4c3d-aefe-53c8649352f0', 'User B'),
('a94c740a-3791-492d-88be-b05cf0b85252', 'User C');
-- Addresses
INSERT INTO
Addresses (ID, UserID, Address)
VALUES
('3b9a8a97-5975-46f9-81ff-2b4b7b74d996', '199f8059-558a-4c6f-aad3-526859cfa88e', 'Address A'), -- User A
('bf640863-0636-4ba4-aa77-b4b20bfff7c0', '7d586bac-1c9e-4c3d-aefe-53c8649352f0', 'Address B'), -- User B
('2c002eae-e0fd-40bf-b09f-c8f582302500', '199f8059-558a-4c6f-aad3-526859cfa88e', 'Address C'), -- User A
('d3be1e3f-0ad4-4d7e-bf43-fe61e890cd32', 'a94c740a-3791-492d-88be-b05cf0b85252', 'Address D'), -- User C
('ab02a7fa-7edc-4b1d-8ef6-ae82b69f9d00', '7d586bac-1c9e-4c3d-aefe-53c8649352f0', 'Address E'), -- User B
('0ea07838-34dd-4fa4-af2f-06babaab75a0', '199f8059-558a-4c6f-aad3-526859cfa88e', 'Address F'); -- User A
-- Counters
INSERT INTO
Counters (ID, Count)
VALUES
('05a7f30c-823c-4502-a866-6ac783050e4f', 0);
コード生成
ソースコードはこちら
sqlcのインストール
ひとまずgo installで入れてみます(他のインストール方法についてはこちら)。
go install github.com/kyleconroy/sqlc/cmd/sqlc@latest
設定ファイル作成
sqlcの設定ファイルを作成します(yaml or json)。
version: 1
packages:
- path: "db"
name: "db"
engine: "mysql"
schema: "schema.sql"
queries: "query.sql"
DBエンジン、生成先と生成元のSQLを指定します。ここでは
- 生成先:db
- パッケージ名:db
- スキーマ定義:
schema.sql
- クエリ定義:
query.sql
SQL作成
まずはschema.sql
でテーブル定義を記述します。
CREATE TABLE Users (
ID VARCHAR(36) NOT NULL PRIMARY KEY,
Name VARCHAR(255) NOT NULL
);
CREATE TABLE Addresses (
ID VARCHAR(36) NOT NULL PRIMARY KEY,
UserID VARCHAR(36) NOT NULL,
Address VARCHAR(255) NOT NULL
);
CREATE TABLE Counters (
ID VARCHAR(36) NOT NULL PRIMARY KEY,
Count BIGINT NOT NULL
);
※Spannerの型が使えないのでMySQLの型に合うように変換します
Spannerの構文だけだと生成時にschema.sql:2:12: syntax error near "STRING(36) NOT NULL,"
エラーが発生します。それはそうですよね。
続いて、流したいクエリをquery.sql
で記述します。
-- name: GetUser :one
SELECT * FROM Users
WHERE ID = ? LIMIT 1;
-- name: GetUserWithAddresses :many
SELECT * FROM Users u
JOIN Addresses a ON u.ID = a.UserID
WHERE u.ID = ?;
-- name: GetCounter :one
SELECT * FROM Counters
WHERE ID = ? LIMIT 1;
-- name: UpdateCounter :exec
UPDATE Counters SET Count = ?
WHERE ID = ?;
基本的な構文なのでクエリは変換不要ですが、@{FORCE_INDEX=HogeIndex}
のようなSpanner固有の構文は使用できません。
nameで指定した名称が、生成されたコードの関数名になります。
コード生成
sqlc generate
生成に成功すると、db
配下にいくつかファイルが生成されます(全文はこちら)。
model
type Address struct {
ID string
Userid string
Address string
}
type Counter struct {
ID string
Count int64
}
type User struct {
ID string
Name string
}
query
func (q *Queries) GetUser(ctx context.Context, id string) (User, error) {
row := q.db.QueryRowContext(ctx, getUser, id)
var i User
err := row.Scan(&i.ID, &i.Name)
return i, err
}
func (q *Queries) UpdateCounter(ctx context.Context, arg UpdateCounterParams) error {
_, err := q.db.ExecContext(ctx, updateCounter, arg.Count, arg.ID)
return err
}
実装
生成されたコードを使用していくつか処理を行ってみます。
-
Users
テーブルの行を取得(JOIN
無し) -
Users
テーブルの行を取得(JOIN
有り) - トランザクションを使用して
Counters
テーブルの行をカウントアップ(10本並列実行)
connection生成等
sqldb, err := sql.Open("spanner", fmt.Sprintf("projects/%s/instances/%s/databases/%s", project, instance, database))
if err != nil {
panic(err)
}
queries := db.New(sqldb)
Users
情報の取得
fmt.Println("------- query: Users -------")
for _, id := range userIDs {
user, err := queries.GetUser(ctx, id)
if err != nil {
panic(err)
}
fmt.Printf("%#v\n", user)
}
fmt.Println("------- query: UsersWithAddresses -------")
for _, id := range userIDs {
useraddrRows, err := queries.GetUserWithAddresses(ctx, id)
if err != nil {
panic(err)
}
for _, useraddr := range useraddrRows {
fmt.Printf("%#v\n", useraddr)
}
}
Counters
の並列カウントアップ
waitNum := 10
var wg sync.WaitGroup
for i := 0; i < waitNum; i++ {
fmt.Printf("------- query: UpdateCounter (%d) -------\n", i)
wg.Add(1)
go func() {
defer wg.Done()
for {
err := incrementCounter(ctx, queries, sqldb)
if err == spannerdriver.ErrAbortedDueToConcurrentModification {
continue
}
if err != nil {
panic(err)
}
break
}
}()
}
wg.Wait()
c, err := queries.GetCounter(ctx, counterID)
if err != nil {
panic(err)
}
fmt.Printf("counter: %d\n", c.Count)
// ...
func incrementCounter(ctx context.Context, queries *db.Queries, sqldb *sql.DB) error {
// Read-Write Transaction
tx, err := sqldb.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return err
}
defer tx.Rollback()
qtx := queries.WithTx(tx)
c, err := qtx.GetCounter(ctx, counterID)
if err != nil {
return err
}
if err := qtx.UpdateCounter(ctx, db.UpdateCounterParams{
ID: c.ID,
Count: c.Count + 1,
}); err != nil {
return err
}
return tx.Commit()
}
connectionさえ準備してあげればあとはqueries
の関数(GetCounter()
, GetUserWithAddresses()
etc.)を呼び出すだけでDB操作が可能になります。
一点、トランザクション操作は少し特殊で
err := incrementCounter(ctx, queries, sqldb)
if err == spannerdriver.ErrAbortedDueToConcurrentModification {
continue
}
Spannerのabort対策は必要です。
If the driver detects that the data that was used by the transaction was changed by another transaction between the initial attempt and the retry attempt, the Aborted error will be propagated to the client application as an
spannerdriver.ErrAbortedDueToConcurrentModification
error.
cf. https://github.com/googleapis/go-sql-spanner#troubleshooting
また、書き込みを伴うDB操作でRead-only Transactionを使用するとspanner: code = "FailedPrecondition", desc = "read-only transactions cannot write"
が発生します。
tx, err := sqldb.BeginTx(ctx, &sql.TxOptions{
ReadOnly: true,
})
動作確認
実行してみます。同時に10本同一行を更新するので何度かAbort-Retryが入って少し時間がかかります(数秒程度)。
$ go run main.go
------- query: Users -------
db.User{ID:"199f8059-558a-4c6f-aad3-526859cfa88e", Name:"User A"}
db.User{ID:"7d586bac-1c9e-4c3d-aefe-53c8649352f0", Name:"User B"}
db.User{ID:"a94c740a-3791-492d-88be-b05cf0b85252", Name:"User C"}
------- query: UsersWithAddresses -------
db.GetUserWithAddressesRow{ID:"199f8059-558a-4c6f-aad3-526859cfa88e", Name:"User A", ID_2:"0ea07838-34dd-4fa4-af2f-06babaab75a0", Userid:"199f8059-558a-4c6f-aad3-526859cfa88e", Address:"Address F"}
db.GetUserWithAddressesRow{ID:"199f8059-558a-4c6f-aad3-526859cfa88e", Name:"User A", ID_2:"2c002eae-e0fd-40bf-b09f-c8f582302500", Userid:"199f8059-558a-4c6f-aad3-526859cfa88e", Address:"Address C"}
db.GetUserWithAddressesRow{ID:"199f8059-558a-4c6f-aad3-526859cfa88e", Name:"User A", ID_2:"3b9a8a97-5975-46f9-81ff-2b4b7b74d996", Userid:"199f8059-558a-4c6f-aad3-526859cfa88e", Address:"Address A"}
db.GetUserWithAddressesRow{ID:"7d586bac-1c9e-4c3d-aefe-53c8649352f0", Name:"User B", ID_2:"ab02a7fa-7edc-4b1d-8ef6-ae82b69f9d00", Userid:"7d586bac-1c9e-4c3d-aefe-53c8649352f0", Address:"Address E"}
db.GetUserWithAddressesRow{ID:"7d586bac-1c9e-4c3d-aefe-53c8649352f0", Name:"User B", ID_2:"bf640863-0636-4ba4-aa77-b4b20bfff7c0", Userid:"7d586bac-1c9e-4c3d-aefe-53c8649352f0", Address:"Address B"}
db.GetUserWithAddressesRow{ID:"a94c740a-3791-492d-88be-b05cf0b85252", Name:"User C", ID_2:"d3be1e3f-0ad4-4d7e-bf43-fe61e890cd32", Userid:"a94c740a-3791-492d-88be-b05cf0b85252", Address:"Address D"}
------- query: UpdateCounter (0) -------
------- query: UpdateCounter (1) -------
------- query: UpdateCounter (2) -------
------- query: UpdateCounter (3) -------
------- query: UpdateCounter (4) -------
------- query: UpdateCounter (5) -------
------- query: UpdateCounter (6) -------
------- query: UpdateCounter (7) -------
------- query: UpdateCounter (8) -------
------- query: UpdateCounter (9) -------
counter: 10
JOINしたデータもGetUserWithAddressesRow
として、SELECTで取得する行に合わせてmodelが自動的に定義されていることがわかります。便利。
おわりに
いかがでしたでしょうか。Spannerでも一応sqlcで良い感じにDB操作ができることがわかりました。ただし、冒頭でも記載した通り若干強引にZetaSQL→MySQLに変換した形なので商用で利用するのは微妙な感じです。
でも、一応下記の通りsqlcでZetaSQLに対応するissueが上がっているので、Spannerでもきちんとした形で利用できるようになる未来は近いかもしれません!
Discussion