DynamoDBのテーブル設計&Goでの実装プラクティス〜GSI Overloading編

業務ではRDBを使うことが多く、DynamoDBにマッチしたテーブル設計・実装をこれまであまり考えてこなかったため、記事としてアウトプットしながら勉強していきたいと思います。
GSI Overloading、隣接関係のリスト設計、転置インデックス、Composite Key、Sparse Indexesあたりに触れていければと思います。今回は、GSI Overloading編。なお、DynamoDBの基本的な用語については理解されている前提とします。

## GSI Overloadingとは？
直訳すると「GSIの多重定義で」、**一つのGSIに複数の検索条件を持たせる**ための手法です。
これだけ聞いても、**どのような場面で使うのか**、**どのようなメリットがあるのか**がわかりにくいと思うので、それぞれ説明します。

### どのような場面で使うのか
**テーブルが持つ複数のカラムに対して検索をかけたい時**に使用します。全く特殊なケースではなく、むしろかなり多くのケースで当てはまります。
例えば以下のようなイベントテーブルがあったときに、IDだけでなく、他のカラム（イベント名、日付、タグ）でも検索したいようなケースです。

|ID|イベント名|日付|タグ|
|--|--|--|--|
|1|イベント1|2021/5/1|タグ1|
|2|イベント2|2021/5/2|タグ2|

RDBを使用する場合は、特に何も考えることなく、この表の通りの設計になるかと思います。

### どのようなメリットがあるのか
GSI Overloadingのメリットを説明するために、まずはRDBと同じようなテーブル構造の設計をDynamoDBで行った場合にどのような問題が発生するかをみていきます。
以下の2つのパターンが考えられます。

#### パターン①：RDBの設計そのまま（GSIなし）
DynamoDBの場合、ハッシュキー（今回の場合はID）のみ、もしくはハッシュキー＋レンジキーでしか検索できないので、こちらのパターンの場合は**全件Scanしてアプリケーション側でFilter処理をかける必要があり、パフォーマンスが悪くなります**。項目数が少なくてScanで問題ない場合以外は基本的にはよくない設計です。

#### パターン②：検索したい全カラムに対してGSIを貼る
GSIを使えばハッシュキーやレンジキー以外で検索をかけることができます（ただし、GSIはデフォルトの設定だと1テーブルにつき20個までという制約がある）。
こちらの構成を使えば、インデックスを使った検索ができるのでパフォーマンスの問題は解決できる一方で、DynamoDBの特性上、**インデックスを作成した分だけRCU/WCUが発生するのでコスト効率が悪い**点と、**アプリケーションが意識する（検索をかけにいく）GSIが増えてコードが煩雑になる**のが問題点かなと思います。

#### GSI Overloadingを使った設計
これらの問題点を解決してくれるのが、GSI Overloadingです。
上であげたイベントテーブルの各カラムに対して横断的に検索するための設計を考えます。GSI Overloadingの考え方で設計すると以下のようになります。

- テーブル

|ID(※ハッシュ)|DataType(※レンジ)|DataValue|
|--|--|--|--|
|1|EventName|イベント1|
|1|Date|2021/5/1|
|1|Tag|タグ1|

- GSI

|DataValue(※ハッシュ)|ID(※レンジ)|DataType|
|--|--|--|--|
|イベント1|1|EventName|
|2021/5/1|1|Date|
|タグ1|1|Tag|

このように設計することで、実際に検索をかけにいくときは**GSIのキーを使った高パフォーマンスな検索**ができ、**GSIの数も節約できコスト効率が良くなる**といったメリットがあります。

## 実装
ServerlessFramework&Goで実装していきます。
ベースとなる環境の構築方法については以下の記事に記載しています。
https://qiita.com/hisamitsu0723/items/00126651c8470e8aa874

◯serverless.yml

こちらについては、特に特別なことはしてません。TableNameの箇所は適当な値を設定してください。

```
resources:
  Resources:
    # DynamoDBの構築
    TestTable:
      Type: "AWS::DynamoDB::Table"
      Properties:
        # キーの型を指定
        AttributeDefinitions:
          - AttributeName: ID
            AttributeType: S
          - AttributeName: DataType
            AttributeType: S
          - AttributeName: DataValue
            AttributeType: S
        # キーの種類を指定（ハッシュorレンジキー）
        KeySchema:
          - AttributeName: ID
            KeyType: HASH
          - AttributeName: DataType
            KeyType: RANGE
        GlobalSecondaryIndexes:
          - IndexName: GSI1
            KeySchema:
              - AttributeName: DataValue
                KeyType: HASH
              - AttributeName: ID
                KeyType: RANGE
            Projection:
              ProjectionType: ALL
        BillingMode: PAY_PER_REQUEST
        # テーブル名の指定
        TableName: HisaTestTable
```

◯Goのソースコード

Goのコードを書くのが初めてなので、突っ込みどころは多いかと思いますが、少しでも参考になれば。
また、下記コードだと対象となるレコードが複数あったとしても1つしか抽出できないという制約がありますが、あしからず。。いずれ直します。

コードにもコメントで番号を振っていますが、全体の流れとしては以下です。
①事前準備（クエリパラメータの取得など）
②GSIに対する検索
③テーブルから必要な属性情報を取得（②だとIDしか取得できないので）
④同じIDのものをまとめて、クライアントが欲しい形に整形する
⑤レスポンスの返却

```
package main

import (
	"bytes"
	"context"
	"encoding/json"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

type Request events.APIGatewayProxyRequest
type Response events.APIGatewayProxyResponse

type ResultStructure struct {
	ID        string
	DataType  string
	DataValue string
}

const TableName = "HisaTestTable"

func Handler(ctx context.Context, request Request) (Response, error) {
	/*
		①事前準備
	*/
	searchWord := request.QueryStringParameters["q"] // リクエストボディの内容を取得
	sess, err := session.NewSession()                // DynamoDBとのセッション定義
	if err != nil {
		panic(err)
	}
	svc := dynamodb.New(sess)

	/*
		②GSIに対する検索
	*/
	searchInput := &dynamodb.QueryInput{
		TableName: aws.String(TableName),
		ExpressionAttributeNames: map[string]*string{
			"#DataValue": aws.String("DataValue"),
		},
		ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
			":dataValue": {
				S: aws.String(searchWord),
			},
		},
		KeyConditionExpression: aws.String("#DataValue = :dataValue"),
		IndexName:              aws.String("GSI1"),
	}
	searchResult, err := svc.Query(searchInput)
	if err != nil {
		panic(err)
	}
	gsiItems := make([]*ResultStructure, 0)
	if err := dynamodbattribute.UnmarshalListOfMaps(searchResult.Items, &gsiItems); err != nil {
		panic(err)
	}

	result := make(map[string]string) // 結果を格納するスライス

	if len(gsiItems) != 0 {
		targetId := gsiItems[0].ID // GSIの検索結果から該当の全IDを取得する　※今回は対象は1つの前提
		/*
			③テーブルから必要な属性情報を取得
		*/
		queryInput := &dynamodb.QueryInput{
			TableName: aws.String(TableName),
			ExpressionAttributeNames: map[string]*string{
				"#ID":        aws.String("ID"),
				"#DataType":  aws.String("DataType"),
				"#DataValue": aws.String("DataValue"),
			},
			ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
				":id": {
					S: aws.String(targetId),
				},
			},
			KeyConditionExpression: aws.String("#ID = :id"),                  // 検索条件
			ProjectionExpression:   aws.String("#ID, #DataType, #DataValue"), // 取得カラム
		}
		queryResult, err := svc.Query(queryInput)
		if err != nil {
			panic(err)
		}
		tableItems := make([]*ResultStructure, 0)
		if err := dynamodbattribute.UnmarshalListOfMaps(queryResult.Items, &tableItems); err != nil {
			panic(err)
		}

		/*
			④同じIDのものをまとめて、クライアントが欲しい形に整形する
		*/
		result["ID"] = tableItems[0].ID
		for _, value := range tableItems {
			result[value.DataType] = value.DataValue
		}
	}

	/*
		⑤レスポンスの返却
	*/
	var buf bytes.Buffer
	body, err := json.Marshal(result)
	if err != nil {
		return Response{StatusCode: 404}, err
	}
	json.HTMLEscape(&buf, body)
	resp := Response{
		StatusCode:      200,
		IsBase64Encoded: false,
		Body:            buf.String(),
		Headers: map[string]string{
			"Content-Type":           "application/json",
			"X-MyCompany-Func-Reply": "hello-handler",
		},
	}

	return resp, nil
}

func main() {
	lambda.Start(Handler)
}
```

## 実装してみての所感
- RDBであればテーブルに1回クエリ発行すれば良いが、GSIで検索かけた後、テーブルでも検索かけないといけないので二度手間
- クライアントが欲しい形にがっつり整形しないといけないので三度手間
- そもそも、設計に手間がかかる

これまでRDBをORMを使って操作していた自分にとっては、やっぱり辛いなという感想でした（Goを使うのが初めてだったので余計辛かったのかも）。とはいえ、Dynamoを選定した場合のインフラ構築の容易さのメリットも捨てがたいので、その他の設計パターンなども引き続き勉強していきたいと思います。

## 参考サイト
- https://www.slideshare.net/AmazonWebServicesJapan/db-20190905?ref=https://dev.classmethod.jp/