【AWS】【Go】GoのSDK(guregu/dynamo)を使ったDynamoDBのテーブル定義とコード設計
DynamoDBとは。
DynamoDBはNoSQLで、速くてサーバレスな奴だが、詳しい説明は他所に任せたい。
DynamoDBをGoで扱うには。
どうやら guregu/dynamo
を使ってやるのは一般的らしい。
しかし、↑や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で、LSIは localIndex
ラベルだが、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難しいけど代替品ないよねって感じで、ありがたく使わせていただいております🙏