AWS SDK for Go v2 における DynamoDB の扱い方

f:id:so-technologies:20220303122723j:plain
妻に描いてもらった Gopher くん
こんにちは。ライクル事業部エンジニアの寺戸です。

ライクルではサービスの基盤に AWS を採用しており、 AWS の各サービスを利用して機能を提供しています。
バックエンドの開発には Go を採用しており、 AWS 関連の処理は基本的に AWS SDK for Go を使用して開発しています。

AWS SDK for Go はバージョン 2 が2021 年 1 月 19 日に一般公開され、約 1 年が経過した頃合いです。
基本的な書き方は v1 とさほど変わりませんが、細かいところでは変わっている印象です。
ただ、 Developer Guide を読んでもバージョン 2 におけるサンプルコードはまだまだ充実しておらず、 DynamoDB まわりの実装でどう書くんだろう?と悩んだケースがあったので今回はそちらをまとめていきます。

ちなみにバージョン 2 向けの Developers Guide には以下のように記載があるため、基本的な使い方やメソッドの詳細を調べるにはバージョン 1 の Developers Guideを参照した方がよさそうです。

Use the AWS SDK for Go Developer Guide to help you install, configure, and use the SDK. The guide provides configuration information, sample code, and an introduction to the SDK utilities.

※DeepL による翻訳

SDK のインストール、設定、および使用には、AWS SDK for Go Developer Guide を使用してください。このガイドでは、設定情報、サンプルコード、および SDK ユーティリティの紹介を提供しています。

DynamoDB の CRUD について

SDK での実装に入る前に SKD で用意されているメソッドたちがどういう動きをするのかを簡単なコードと共にまとめます。

今回サンプルで作ったコードの全体

v1 の SDK とはパッケージのパスや命名が異なっているので v2 で書く場合の参考まで。
この後 CRUD ごとに解説します。

package main

import (
    "context"
    "fmt"
    "log"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"
    "github.com/aws/aws-sdk-go-v2/feature/dynamodb/expression"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb"
    "github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
)

const (
    region string = "ap-northeast-1"
)

type User struct {
    Name   string `dynamodbav:"name"` // NOTE: Name をパーティションキーとしている
    Email  string `dynamodbav:"email"`
    Age    uint   `dynamodbav:"age"`
    Height uint   `dynamodbav:"height"`
    Weight uint   `dynamodbav:"weight"`
}

func main() {
    var err error
    var ctx = context.Background()

    // DynamoDB クライアントの生成
    c, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
    if err != nil {
        fmt.Printf("load aws config: %s\n", err.Error())
        return
    }
    client := dynamodb.NewFromConfig(c)

    // 新規レコードの追加
    putInput := User{
        Name:   "Taro Tanaka",
        Email:  "tanaka@example.com",
        Age:    25,
        Height: 175,
        Weight: 65,
    }
    av, err := attributevalue.MarshalMap(putInput)
    if err != nil {
        fmt.Printf("dynamodb marshal: %s\n", err.Error())
        return
    }
    _, err = client.PutItem(ctx, &dynamodb.PutItemInput{
        TableName: aws.String("user"),
        Item:      av,
    })
    if err != nil {
        fmt.Printf("put item: %s\n", err.Error())
        return
    }

    // 既存レコードの取得
    getInput := &dynamodb.GetItemInput{
        TableName: aws.String("user"),
        Key: map[string]types.AttributeValue{
            "name": &types.AttributeValueMemberS{
                Value: "Taro Tanaka",
            },
        },
    }
    output, err := client.GetItem(ctx, getInput)
    if err != nil {
        fmt.Printf("get item: %s\n", err.Error())
        return
    }
    gotUser := User{}
    err = attributevalue.UnmarshalMap(output.Item, &gotUser)
    if err != nil {
        fmt.Printf("dynamodb unmarshal: %s\n", err.Error())
        return
    }
    fmt.Println(gotUser)

    // 既存レコードの更新
    update := expression.UpdateBuilder{}.Set(expression.Name("height"), expression.Value(180))
    // 他のフィールドも更新したいなら以下のように追加可能
    update = update.Set(expression.Name("weight"), expression.Value(70))

    expr, err := expression.NewBuilder().WithUpdate(update).Build()
    if err != nil {
        fmt.Printf("build update expression: %s\n", err.Error())
        return
    }
    updateInput := &dynamodb.UpdateItemInput{
        TableName: aws.String("user"),
        Key: map[string]types.AttributeValue{
            "name": &types.AttributeValueMemberS{
                Value: "Taro Tanaka",
            },
        },
        ExpressionAttributeNames:  expr.Names(),
        ExpressionAttributeValues: expr.Values(),
        UpdateExpression:          expr.Update(),
    }

    _, err = client.UpdateItem(ctx, updateInput)
    if err != nil {
        fmt.Printf("update item: %s\n", err.Error())
        return
    }

    // レコードの削除
    deleteInput := &dynamodb.DeleteItemInput{
        TableName: aws.String("user"),
        Key: map[string]types.AttributeValue{
            "name": &types.AttributeValueMemberS{
                Value: "Taro Tanaka",
            },
        },
    }

    _, err = client.DeleteItem(ctx, deleteInput)
    if err != nil {
        fmt.Printf("delete item: %s\n", err.Error())
        return
    }
}

テーブルの用意

以下のような DynamoDB のテーブルを用意しました。 今回はパーティションキーのみを設定しています。

f:id:so-technologies:20220302182559p:plain

クライアント生成まで

実行時のプロファイルを元に DynamoDB クライアントを生成します。

var ctx = context.Background()

// DynamoDB クライアントの生成
c, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
if err != nil {
    log.Fatalln("can't load aws config")
    return
}
client := dynamodb.NewFromConfig(c)

Create

PutItem メソッドを使用します。

type User struct {
    Name   string `dynamodbav:"name"` // NOTE: Name をパーティションキーとしている
    Email  string `dynamodbav:"email"`
    Age    uint   `dynamodbav:"age"`
    Height uint   `dynamodbav:"height"`
    Weight uint   `dynamodbav:"weight"`
}

// 新規レコードの追加
putInput := User{
    Name:   "Taro Tanaka",
    Email:  "tanaka@example.com",
    Age:    25,
    Height: 175,
    Weight: 65,
}
av, err := attributevalue.MarshalMap(putInput)
if err != nil {
    fmt.Printf("dynamodb marshal: %s\n", err.Error())
    return
}
_, err = client.PutItem(ctx, &dynamodb.PutItemInput{
    TableName: aws.String("user"),
    Item:      av,
})
if err != nil {
    fmt.Printf("put item: %s\n", err.Error())
    return
}

dynamodbav タグを指定することで DynamoDB のキーと構造体をマッピングすることが出来ます。

dynamodb.PutItemInput 構造体に作成するレコードのデータを埋め込んでいくのですが、愚直に書いていくととても大変なので attributevalue パッケージを利用して構造体をマップに変換します。

(参考) https://docs.aws.amazon.com/ja_jp/sdk-for-go/v1/developer-guide/dynamo-example-create-table-item.html

※v1 のドキュメントなのでパッケージ構成が異なっている点に注意

Read

GetItem メソッドを使用します。

// 既存レコードの取得
getInput := &dynamodb.GetItemInput{
    TableName: aws.String("user"),
    Key: map[string]types.AttributeValue{
        "name": &types.AttributeValueMemberS{
            Value: "Taro Tanaka",
        },
    },
}
output, err := client.GetItem(ctx, getInput)
if err != nil {
    fmt.Printf("get item: %s\n", err.Error())
    return
}
gotUser := User{}
err = attributevalue.UnmarshalMap(output.Item, &gotUser)
if err != nil {
    fmt.Printf("dynamodb unmarshal: %s\n", err.Error())
    return
}
fmt.Println(gotUser)
// Output: {Taro Tanaka tanaka@example.com 25 175 65}

レコードを取得する際はパーティションキーを指定する必要があります。

今回、 name というフィールドをパーティションキーとしてテーブルを作成したため、 Key フィールドの AttributeValue のキー名に name を指定しています。

ちなみに、今回はテーブル作成時にパーティションキーしか指定しなかったのでコード上でもパーティションキーのみの指定になっていますが、ソートキーも指定した場合はコード上でもソートキーの指定が必要になります。(Key のマップの要素が 1 つ増えるだけ)

name は DynamoDB 上では文字列として扱うため AttributeValueMemberS を指定していますが、数値で扱いたい時は AttributeValueMemberN にするなど、適切な構造体を選択しましょう。 この辺りがドキュメントなどから見つけられなかったので少し躓いたポイントでした。

Update

UpdateItem メソッドを使用します。

// 既存レコードの更新
update := expression.UpdateBuilder{}.Set(expression.Name("height"), expression.Value(180))
// 他のフィールドも更新したいなら以下のように追加可能
update = update.Set(expression.Name("weight"), expression.Value(70))

expr, err := expression.NewBuilder().WithUpdate(update).Build()
if err != nil {
    fmt.Printf("build update expression: %s\n", err.Error())
    return
}
updateInput := &dynamodb.UpdateItemInput{
    TableName: aws.String("user"),
    Key: map[string]types.AttributeValue{
        "name": &types.AttributeValueMemberS{
            Value: "Taro Tanaka",
        },
    },
    ExpressionAttributeNames:  expr.Names(),
    ExpressionAttributeValues: expr.Values(),
    UpdateExpression:          expr.Update(),
}

_, err = client.UpdateItem(ctx, updateInput)
if err != nil {
    fmt.Printf("update item: %s\n", err.Error())
    return
}

いよいよ複雑になってきました。 ただ、 UpdateItem の場合でも基本的に

  • パーティションキーを指定する
  • 操作対象のキーと値 (Attribute) を指定する

ということは同じです。

expression パッケージを使用して Attribute の更新式をビルドすることで更新します。

詳しくは expression パッケージのドキュメントをお読みください…と言いたいところですが、これもまた v1 のドキュメントなので参考にする際はご注意ください。

(参考) https://docs.aws.amazon.com/sdk-for-go/api/service/dynamodb/expression/

Delete

DeleteItem メソッドを使用します。

// レコードの削除
deleteInput := &dynamodb.DeleteItemInput{
    TableName: aws.String("user"),
    Key: map[string]types.AttributeValue{
        "name": &types.AttributeValueMemberS{
            Value: "Taro Tanaka",
        },
    },
}
_, err = client.DeleteItem(ctx, deleteInput)
if err != nil {
    fmt.Printf("delete item: %s\n", err.Error())
    return
}

Delete の場合はシンプルにパーティションキーを指定するだけですね。

PutItem と UpdateItem の違い

DynamoDB を操作する際に重要だと感じたところは PutItemUpdateItem の挙動の違いです。
以下にまとめます。

PutItem

https://docs.aws.amazon.com/sdk-for-go/api/service/dynamodb/#DynamoDB.PutItem

Creates a new item, or replaces an old item with a new item. If an item that has the same primary key as the new item already exists in the specified table, the new item completely replaces the existing item. You can perform a conditional put operation (add a new item if one with the specified primary key doesn't exist), or replace an existing item if it has certain attribute values. You can return the item's attribute values in the same operation, using the ReturnValues parameter.

※DeepL による翻訳

新しいアイテムを作成するか、古いアイテムを新しいアイテムで置き換えます。指定したテーブルに新しいアイテムと同じ主キーを持つアイテムが既に存在する場合、新しいアイテムは既存のアイテムを完全に置き換えます。条件付き put 操作 (指定した主キーを持つ項目が存在しない場合に新しい項目を追加する) や、既存の項目が特定の属性値を持っている場合にその項目を置き換えることもできます。ReturnValues パラメータを使えば、同じ操作で項目の属性値を返すことができます。

指定したキーのレコードが存在していなければ新しいレコードとして登録するし、存在していればその情報を与えられた情報で全て上書きする、なるほど。

つまり、 PutItem 実行時に指定されていないキーについては削除されてしまう、ということですね。

メソッド名の通り、HTTP でいう PUT と同じ動きと考えればよさそうです。

UpdateItem

https://docs.aws.amazon.com/sdk-for-go/api/service/dynamodb/#DynamoDB.UpdateItem

Edits an existing item's attributes, or adds a new item to the table if it does not already exist. You can put, delete, or add attribute values. You can also perform a conditional update on an existing item (insert a new attribute name-value pair if it doesn't exist, or replace an existing name-value pair if it has certain expected attribute values).

※DeepL による翻訳

既存のアイテムの属性を編集し、まだ存在しない場合は新しいアイテムをテーブルに追加します。属性の値を入れたり、削除したり、追加したりすることができます。また、既存のアイテムに対して条件付き更新を行うこともできます(存在しない場合は新しい属性の名前と値のペアを挿入し、特定の期待される属性値を持っている場合は既存の名前と値のペアを置き換えます)。

対してこちらは指定したキーのレコードの、指定したフィールドだけに対して操作が行われるということですね。

HTTP でいう PATCH と同じ動きと考えればよさそうです。

終わりに

今回は AWS SDK for Go v2 における DynamoDB の基本的な操作についてまとめました。
公式ドキュメントを見てもなかなか正解の書き方に辿り着けずに苦労したので、同じように悩んでいる方の参考になれば幸いです。

SO Technologies では一緒に開発を行っていただけるエンジニアを大募集しております!
Go や AWS を使用した開発に少しでも興味があれば是非お気軽にお声がけいただければと思います!