2️⃣

Go: DynamoDB(PartiQL)用のDBドライバを実装してみた

2024/12/11に公開

はじめに

今年の春頃からmiyamo2/dynmgrmというGORMドライバの開発を始めました
https://zenn.dev/miyamo2/articles/9603130983545c

miyamo2/dynmgrmではDBドライバにgodynamoを使用していますが
ちょっとワケあってv0.9.0からはフォーク版のmiyamo2/godynamoに切り替えています

今後どうせメンテしていくのであれば...と思い
0ベースでぼくの考える最強PartiQLドライバを作るに至りました

作ったもの

https://github.com/miyamo2/pqxd

余談ですがpgxに倣ってpqxとしたかったものの
字面があまりにpgxと似すぎているのでpqxdとしました

サンプルコード

package main

import (
	"context"
	"database/sql"
	"fmt"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/miyamo2/pqxd"
	"log"
	"time"
)

func main() {
	ctx := context.Background()
	cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("ap-northeast-1"))
	if err != nil {
		log.Fatalf("unable to load SDK config, %v", err)
	}
	db := sql.OpenDB(pqxd.NewConnector(cfg))
	if db == nil {
		log.Fatal(err)
	}
	if err := db.Ping(); err != nil {
		log.Fatal(err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	rows, err := db.QueryContext(ctx, `SELECT id, name FROM "users"`)
	if err != nil {
		log.Fatalf("something happend. err: %s\n", err.Error())
		return
	}

	for rows.NextResultSet() { // page feed with next token
		for rows.Next() {
			var (
				id   string
				name string
			)
			if err := rows.Scan(&id, &name); err != nil {
				fmt.Printf("something happend. err: %s\n", err.Error())
				continue
			}
			log.Printf("id: %s, name: %s\n", id, name)
		}
	}
}

vs godynamo - Transaction

godynamo

godynamoではTransaction内でSELECTを使用することができません

pqxd

pqxdではPK(, SK)を使用した結果が一意になるクエリ(GetItem)に限り使用することができます

tx, err := db.Begin()
if err != nil {
    return err
}

ctx := context.Background()

row := tx.QueryRowContext(ctx, `SELECT id, name FROM "users" WHERE id = ?`, "1")
if err != nil {
    tx.Rollback()
    return err
}
var (
    id string
    name string
)
if err := row.Scan(&id, &name); err != nil {
    fmt.Printf("something happend. err: %s\n", err.Error())
    return
}
fmt.Printf("id: %s, name: %s\n", id, name)
row := tx.QueryRowContext(ctx, `SELECT id, name FROM "users" WHERE id = ?`, "1")
var (
    id string
    name string
)
if err := row.Scan(&id, &name); err != nil {  // ここでTransactionがCommitされる  
    fmt.Printf("something happend. err: %s\n", err.Error())
    return
}
row := tx.QueryRowContext(ctx, `SELECT id, name FROM "users" WHERE id = ?`, "2")
// このクエリはTransaction外で実行される
if err := row.Scan(&id, &name); err != nil { 
    fmt.Printf("something happend. err: %s\n", err.Error())
    return
} 

vs godynamo - RETURNING

godynamo

godynamoでもサポートはされていますがこの機能にはスキーマレスDB特有の課題があります

row := db.QueryRowContext(
    context.Background(),
    `UPDATE "users" SET name = ? SET nickname = ? WHERE id = ? RETURNING MODIFIED OLD *`,
    "Robert",
    "Bob",
    "2",
)

var id, name, nickname string
if err := row.Scan(&id, &nickname, &name); err != nil {
    fmt.Printf("something happend. err: %s\n", err.Error())
    return
}

DynamoDBではPK(,SK)以外の項目は全て未定義になりえます
イメージとしては下記のような状態です

id(PK) name(GSI-PK) nickname disabled
1 宮本太郎 miyamo2
2 宮本花子
3
4 NULL true

ではRETURNING *の結果を取得する際、
row.Scanに渡す変数の数や順番はどのようにするのが正解でしょうか?

...答えとしては分からない(itemによって変動する)になると思います

pqxd

pqxdではこの課題へのアプローチとしてRETURNING句でのカラム指定をサポートしています

row := db.QueryRowContext(
    context.Background(),
    `UPDATE "users" SET name = ? SET nickname = ? WHERE id = ? RETURNING MODIFIED OLD name, nickname, disabled`,
    "Robert",
    "Bob",
    "2",
)

var (
    name sql.NullString
    nickname sql.NullString
    disabled sql.NullBool
)
if err := row.Scan(&name, &nickname, &disabled); err != nil {
    fmt.Printf("something happend. err: %s\n", err.Error())
    return
}
if name.Valid {
    fmt.Printf("name: %s\n", name.String)
}
if nickname.Valid {
    fmt.Printf("nickname: %s\n", nickname.String)
}
if disabled.Valid {
    fmt.Printf("disabled: %v\n", disabled.Bool)
}

取得するカラムやその順番が固定され、仮に項目が未定義だった場合はnilとなります

vs godynamo - ページング

godynamo

自動で最後までフェッチしたうえでResultSetを返します
https://github.com/btnguyen2k/godynamo/blob/main/README.md#caveats

pqxd

NextResultSetによって再取得を行います
そのためループ途中でerrorが発生し早期リターンなどを行う場合は
DynamoDBへクエリを発行する回数をgodynamoより減らせる可能性があります

for rows.NextResultSet() { // 初回は何もしない、二回目以降は残りのResultSetを取得
    for rows.Next() {
        var (
            id   string
            name string
        )
		if err := rows.Scan(&id, &name); err != nil {
			return err
		}
		if err := RelativeValidate(name); err != nil {
			return err
		}
        fmt.Printf("id: %s, name: %s\n", id, name)
    }
}

vs godynamo - aws.Configの連携

godynamo

godynamoの場合RegisterAWSConfigを利用してaws.Configを登録することができます

package main

import (
	"database/sql"
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/btnguyen2k/godynamo"
)

func init () {
	ctx := context.Background()
	cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("ap-northeast-1"))
	if err != nil {
		log.Fatalf("unable to load SDK config, %v", err)
	}
	godynamo.RegisterAWSConfig(cfg)
}

func main() {
	driver := "godynamo"
	db, err := sql.Open("godynamo", "")
	if err != nil {
		log.Fatal(err)
	}
}

RegisterAWSConfigで登録されたaws.Configsync.RWMutexによって排他制御はされているものの
driver.Openの度に参照されるため、アプリケーション起動後に書き換えを行ってしまうと
以前・以降に作られたコネクションが参照するaws.Configに差異が出てしまいます
これはgodynamoによって生成された全てのsql.DBに影響するため
下記のような使い方はできません

godynamo.RegisterAWSConfig(cfg)
db1, _ := sql.Open("godynamo", "")

cfg = LoadAnotherConfig()
godynamo.RegisterAWSConfig(cfg)
db2, _ := sql.Open("godynamo", "")

pqxd

pqxdではaws.ConfigでDynamoDBと接続するためのdriver.Connectorを提供しているため
よりシンプル、かつ安全にaws.Configを連携することができます

package main

import (
	"context"
	"database/sql"
	"fmt"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/miyamo2/pqxd"
	"log"
	"time"
)

func main() {
	ctx := context.Background()
	cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("ap-northeast-1"))
	if err != nil {
		log.Fatalf("unable to load SDK config, %v", err)
	}
	db := sql.OpenDB(pqxd.NewConnector(cfg))
	if db == nil {
		log.Fatal("failed to get db")
	}
}

pqxdでまだできないこと

godynamoでサポートしている機能のうち
CREATE TABLEなどのDDLの実装が現状間に合っていません

PartiQL in DynamoDB自体はCRUD以外の構文を提供していないので
DQLの構文を採用する予定です
https://dql.readthedocs.io/en/latest/topics/queries/create.html

Discussion