nがひとつ多い。

えぬなおの技術的なことを書いていくとこ。

【AWS】【Go】GoのSDK(guregu/dynamo)を使ったDynamoDBのテーブル定義とコード設計

DynamoDBとは。

DynamoDBはNoSQLで、速くてサーバレスな奴だが、詳しい説明は他所に任せたい。

www.ketancho.net

dev.classmethod.jp

DynamoDBをGoで扱うには。

どうやら guregu/dynamo を使ってやるのは一般的らしい。

qiita.com

しかし、↑やREADMEにはINDEXの話がない・・・手探りでやったので、やり方を記す。

guregu/dynamoでモデルを作る。

モデル(構造体化)

うーんこればっかりはGoの型の制約と構造体にピッタリマッピングさせたいからなのか少し分かりにくい。

type Hoge struct {
    UserID   string    `dynamo:"ID,hash"`
    Created  int       `dynamo:"Created,range"`
    Seq      int64     `dynamo:"Seq,range" localIndex:"Seq-index,range"`
    Category string    `dynamo:"Category" index:"Category-KeyID-index"`
    KeyID    string    `dynamo:"KeyID" index:"Category-KeyID-index"`
}

JSONみたいに dynamo ってラベルをつけると、テーブルに必要なattributeが後の Scan() メソッドとかやる時に読み込まれるのでつけておく。 分かりにくいのがGSIとLSIで、LSIlocalIndex ラベルだが、GSIは index ラベルをつけるらしい・・・・。
んで、以下のような関数で定義すればいい。

テーブル作成

こんな感じの関数を定義していく。

func SetupDdbSchema(tableStr string) error {
    ddb := dynamo.New(session.Must(session.NewSession()), aws.NewConfig().WithRegion(os.Getenv("ap-northeast-1")))

    input := ddb.CreateTable(tableStr, Hoge{}).
        Provision(1, 1).
        Project("Seq-index", dynamo.KeysOnlyProjection)

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

    return input.RunWithContext(ctx)
}

ポイントは無論 ddb.CreateTable() 関数だ。 Provision() メソッドは要は awscliでいう--provisioned-throughput '{"ReadCapacityUnits": 1,"WriteCapacityUnits": 1}' で、キャパシティユニットを決める。あらかじめGSIにたいしてユニットを決めたい場合は ProvisionIndex() を使う。

一方 Project() メソッドはawscliで --local-secondary-indexes とかでKeySchemaにて指定している部分だ。 例えばLSIでQueryを出したとき、KEYだけ持ってきたければ↑みたいに dynamo.KeysOnlyProjectionを第二引数に渡せばいいし、追加でattributeを引きたいのであれば dynamo.IncludeProjection を指定して持ってきたいキーとattributeを第二、第三引数に渡せばいいとなる。

Goでモデル作成する時のオレオレ戦略

尺が余ったので、俺がguregu/dynamoをGoで扱った時のコード設計をいかに記す。

グローバルに使い回すawsclientを定義。

var globalAwsClient *awsClient

type awsClient struct {
    Session        *session.Session
    S3Client       *s3Client
    DynamoDBClient *dynamoDBClient
...
}

これは *session.Session を使いまわしたい為。公式に使いまわせと書いてあるので
別にS3ClientとかDynamoDBClientとかを分けなくてもいいが、clientはサービスごとに分けると、色々メソッドを安全に使えると思う。もっと言えばinterfaceでDIしておけばいいかもしれないが、本題から逸れるので略。

DynamoDBを触る時に使うクライアント型を定義

type dynamoDBClient struct {
    Config            *aws.Config
    DynamoDBConfigMap map[string]dynamodbConfig // table名で引く
}

type dynamodbConfig struct {
    HashKey               string
    RangeKey              string
    LocalSecondaryIndexes localSecondaryIndexMap // ローカルセカンダリインデックスは複数個ありえるので、値として取るときはmapのキー指定で・・・。
}

type localSecondaryIndexMap map[string]string // LSIのindex名とそれに対応するrange/hashキー

面倒だと思うのは、DynamoDBの場合はテーブルごとにキーとIndexが分かれる事にある。
なので、今回は DynamoDBのスキーマを定義するごとにmap[<テーブル名>]の形で、HashKeyとかRangeKeyを引く形にしてみた。
LSIはテーブルごとに複数個あり得るので、参照型かなと。インデックスとそれに対応するキーは常に1:1なので配列でもいいが、localSecondaryIndex[0] って書くよりmapで LocalSecondaryIndexes["Seq-index"] って書いて値引いた方が可読性高く思えたのでそっちで。

なお、もしGSIとかを使う場合は、適当に以下のような構造体でも設定しておけばいいと思われる。

type dynamodbConfig struct {
...
  GlobalSecondaryIndexes globalSecondaryIndexMap
...
}

type globalSecondaryIndexMap map[string]globalSecondaryIndex

type globalSecondaryIndex struct {
  IsHashOnly   bool
  HashKey      string
  RangeKey     string
}

New

初期化。環境によって変わるだろうところなのだが、今んとここんな感じでやってる。

func newdynamodbConfig(hk, rk string, lsm localSecondaryIndexMap) dynamodbConfig {
    if len(lsm) == 0 {
        lsm = map[string]string{}
    }
    return dynamodbConfig{
        HashKey:               hk,
        RangeKey:              rk,
        LocalSecondaryIndexes: lsm,
    }
}

func NewAwsClient() {
    cfg := aws.NewConfig().WithRegion(os.Getenv("AWS_REGION"))
    dcfg := cfg
    if os.Getenv("ENVIRONMENT") == "local" {
        dcfg.Endpoint = // テスト用のdockerとか
        ...
    }

    // DynamoDBのテーブルの仕様とか変わったらここを直す。
    var actTable = map[string]dynamodbConfig{
        "Hoge": newdynamodbConfig(
            "ID",
            "Created",
            localSecondaryIndexMap{
                "Seq-index": "Seq",
            },
        ),
    }

    globalAwsClient = &awsClient{
        Session: session.Must(session.NewSession()),
        DynamoDBClient: &dynamoDBClient{
            Config:            dcfg,
            DynamoDBConfigMap: actTable,
        },
...
    }
}

Scan

まぁ普通

func (a *awsClient) Scan(ctx context.Context, tableStr string) ([]Hoge, error) {
    ddb := dynamo.New(a.Session, a.DynamoDBClient.Config)
    table := ddb.Table(tableStr)

    var results = []Hoge{}
    if err := table.Scan().AllWithContext(ctx, &results); err != nil {
        return []Activity{}, xerrors.Errorf("error_msg: %w", err)
    }
    return results, nil
}

GetItem

可読性高く、変更にも強い・・・と思ってる。

func (a *awsClient) GetItem(ctx context.Context, tableStr string) ([]Hoge, error) {
    ddb := dynamo.New(a.Session, a.DynamoDBClient.Config)
    table := ddb.Table(tableStr)

    dconfig, ok := a.DynamoDBClient.DynamoDBConfigMap[tableStr]
    if !ok {
        return []Hoge{}, xerrors.New(tableStr + " is not found in api config for dynamodb")
    }

    var results = []Hoge{}
    if err := table.Get(dconfig.HashKey, 2).Range(dconfig.RangeKey, dynamo.Greater, 222).AllWithContext(ctx, &results); err != nil {
        return []Hoge{}, xerrors.Errorf("error_msg: %w", err)
    }
    return results, nil
}

table.Get(dconfig.HashKey, 2).Range(dconfig.RangeKey, dynamo.Greater, 222).AllWithContext(ctx, &results) が本体。
説明するまでもないが、結局HashKeyもRangeKeyもテーブル取り壊すまで変わらない訳だし、ならコードに埋め込む形でやればいいよねってスタイル。
構造体から取り出す形にしているのでテストもしやすいはず。

LSI使ってGET

インデックスとか使って引く時。

func (a *awsClient) LSIGet(ctx context.Context, tableStr, lsIndex string) ([]Hoge, error) {
    ddb := dynamo.New(a.Session, a.DynamoDBClient.Config)
    table := ddb.Table(tableStr)

    dconfig, ok := a.DynamoDBClient.DynamoDBConfigMap[tableStr]
    if !ok {
        return []Hoge{}, xerrors.New(tableStr + " is not found in api config for dynamodb")
    }

    var results = []Hoge{}
    lsi, ok := dconfig.LocalSecondaryIndexes[lsIndex]
    if !ok {
        return []Hoge{}, xerrors.New(lsIndex + " is not found in api config for dynamodb")
    }

    if err := table.Get(dconfig.HashKey, 2).Index(lsIndex).Range(lsi, dynamo.Greater, 10000).AllWithContext(ctx, &results); err != nil {
        return []Hoge{}, xerrors.Errorf("error_msg: %w", err)
    }
    return results, nil
}

lsi, ok := dconfig.LocalSecondaryIndexes[lsIndex]LSIに事前に定義したキーで引く。このキーに関してもテーブル取り壊すまで変わらない訳だし、ならコードに埋め込む形でやればいいよねってスタイル。

感想

DynamoDB難しいけど代替品ないよねって感じで、ありがたく使わせていただいております🙏