Go gorm mysql の構成でデッドロックに立ち向かう ~retry処理, retry-go~

今回はMySQLにおけるデッドロックが起きるパターンとそれにどう対応するかまとめていきたいと思います。

RDBにおけるデッドロックが起きるパターン

デッドロックとはRDB(InnoDB)に複数リクエストが行われ、特定の条件下で同時に貼られたトランザクションが互いに終了を待つ状態でロックがかかってしまう事です。

特定の条件下と書いたように、デッドロックが起こるパターンはいくつも存在しますが、今回は代表例としてindexが貼られたテーブルにおけるネクスキーロックによってデッドロックが発生するパターンを紹介します。


例えばuser が tweet(つぶやき)できるサービスがあったとします。 以下のようなテーブルを作成します。

drop table if exists users;
create table if not exists users
(
  id      int unsigned not null primary key auto_increment,
  name    varchar(128) not null,
) character set utf8mb4 collate utf8mb4_bin;

drop table if exists tweets;
create table if not exists tweets
(
  id          int unsigned not null primary key auto_increment,
  user_id     int unsigned not null,
  text varchar(256) not null,
  CONSTRAINT fk_tweets_users FOREIGN KEY (user_id) REFERENCES users (id)
) character set utf8mb4 collate utf8mb4_bin;

userとtweetは1対Nの関係で tweetsテーブルのuser_idは外部キーとなり、MySQLでは外部キーに対して自動でインデックスが貼られます。

ここまではよくある構成ですね。

ここでtweetの作成にある制約をつけます。 例えば、 userはtweetを10個までしか作成できない としましょう。

SQLを実行しながらuserがtweetを作成する流れを見て行きます。

MySQLのクライアントを開きます。

// トランザクション開始
BEGIN;

// user_idを元にtweetsをカウントする FOR UPDATEを付ける
SELECT COUNT(user_id) FROM tweets WHERE user_id = 1 FOR UPDATE;

// tweetを作成
INSERT INTO tweets (user_id, text) VALUES (1, ツイート1);

// トランザクション終了
COMMIT;

SELEC文では末尾にFOR UPDATEを付ける事で排他ロックをかけてuser_idが該当するレコードの更新を防ぎます。 もしこの間に同等のuser_idでレコードが作成または削除された場合 userはtweetを10個までしか作成できないというロジックに対して整合性を担保できなくなります。

単純に上記のトランザクションが流れても特にデッドロックは発生しません。


次にデッドロックが起きるパターンを見て行きます。

まずはテーブルをtruncateして初期状態に戻し、user_id = 1 のtweetを1件作成します。*usersテーブルにはuser1(ID = 1),user2(ID = 2)を用意してください。

TRUNCATE tweets;

INSERT INTO tweets (user_id, text) VALUES (1, ツイート1);

これでuser1のtweetが1件のみある状態になりました。

ここからデッドロックを再現して行きます。 user1とuser2が同時にtweetを作成するというシナリオです。

user1

BEGIN;

SELECT COUNT(user_id) from tweets where user_id = 1 for update;

//別クライアントからuser2の処理

INSERT INTO tweets (user_id, text) VALUES (1, ツイート2);

//user2のギャップロック待ち

user2

BEGIN;

SELECT COUNT(user_id) from tweets where user_id = 2 for update;

// user1がINSERTを待機している。
INSERT INTO tweets (user_id, text) VALUES (2, ツイートA);

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

user2の方でデッドロックが起きてしまいました。

これはuser2が初めてtweetを作成すると同時にuser1もtweetを作成したという時に起きる現象です。

初めてtweetするというのがデッドロックが起きる要因となります。 詳しく見て行きます。

ネクスキーロック

ネクスキーロックとはRDBにおけるファントム現象を防ぐロック方式で、インデックス行ロックとギャップロックを組み合わせたものです。

user2ではインデックスカラムであるuser_idに対し WHERE user_id = 2 で検索をしています。この時結果が0の場合tweetsテーブルに+-無限大のギャップに対してギャップロックが掛かります。

次にuser1ではUPDATE句で排他ロックをかけようとしますが、user2のギャップロックがかかっている為user1の解除待ちになります。

そしてuser2からもUPDATEを実行した時にuser1と同じく排他ロックをかけてしまいデッドロックに到達するという訳です。

もしこの時user2にuser_id = 2のtweetが1レコードでもあればデッドロックは発生しません。


以上がネクスキーロックによるデッドロックパターンの一例です。

デッドロックを掛けないようにするのは重要ですが、上記のパターンのようにどうしても発生する場面は訪れます。

次の章ではデッドロックに対してトランザクションの再試行という方法で対策していきたいと思います。

デッドロックの対策

MySQLデッドロックに対する対処方法です。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 14.2.11 デッドロックの対処方法

今回はドキュメントにもあるようにトランザクションの再試行で対策してみたいと思います。

デッドロックが原因でトランザクションに失敗した場合に、そのトランザクションを再発行できるように常に準備しておきます。デッドロックは危険ではありません。再度試してください。

他にも

  • 分離レベルを下げる
  • テーブルロックをする

という方法がありますが、影響範囲を考えると デッドロックした場合はリトライ処理をする と統一した方が汎用性が高い気がします。

Go gormを使ったトランザクション

Go言語とgormを使ってまずはトランザクションを実装したいと思います。

以下、詳細は省きますがクリーンアーキテクチャなどで用いられる、interfaceを用いてトランザクションを貼るパターンです。 第二引数fnにSQLビジネスロジックが入る想定です。 今回はtweetのカウントと作成などがこの引数に入ってきます。

func (t *txRepository) WithTx(ctx context.Context, fn func(context.Context) error) error {

        db := t.db.NewConn(ctx)
        tx := db.Begin()

        ctx = context.WithValue(ctx, &txKey, tx)

        // fnの中でtweetのカウント、作成などを行なっている
        err := fn(ctx)

        if err != nil {
            if dbErr := tx.Rollback().Error; dbErr != nil {
                err = dbErr
            }
        } else {
            if dbErr := tx.Commit().Error; dbErr != nil {
                err = dbErr
            }
        }

        return err
}

呼び出す際は以下のようにします。

func (us *tweetInteractor) CreateTweet(ctx context.Context, tweet *tweet.Tweet) (*tweet.Tweet, error) {


    var newTweet *tweet.Tweet
    //db transaction
    err = us.tx.WithTx(ctx, func(ctx context.Context) error {
         count, err = us.tweetRepository.CountTweetsByUserID(ctx, tweet.UserID)
                  if err != nil {
            return err
        }

                 if count >= 10 {
                   return errors.New("count over")
                 }

        newTweet, err = us.tweetRepository.Save(ctx, trs)
        if err != nil {
            return err
        }

        return nil
    })
    if err != nil {
        return nil, err
    }

  return newTweet, nil
}

以上がリトライ処理を行わないプレーンな実装です。 このままだとデッドロックが起きた場合クライアントにはそのままエラーを返す事になります。

次にDBトランザクションの実装部分にリトライ処理を実装して行きたいと思います。

リトライ処理

リトライ処理は今回のようにDB間だけでなくマイクロサービスや連携APIをコールする時にも用いられます。APIを使用するサーバーでは外側にあるサービスの安全性は保証できない為です。その為、あらかじめ失敗した時を想定してリクエストを繰り返し送れるようにしておくのがリトライ処理という事です。

先ほどのトランザクション処理にシンプルにリトライ処理を実装してみます。

func (t *txRepository) WithTx(ctx context.Context, fn func(context.Context) error) error {
    txFn := func() error {
        db := t.db.NewConn(ctx)
        tx := db.Begin()

        ctx = context.WithValue(ctx, &txKey, tx)

        err := fn(ctx)
        if err != nil {
            if dbErr := tx.Rollback().Error; dbErr != nil {
                err = dbErr
            }
        } else {
            if dbErr := tx.Commit().Error; dbErr != nil {
                err = dbErr
            }
        }

        return err
    }


    // 初回の実行
    err := txFn()

    if isDeadLock(err) {
        retry := 3
        for i := 0; i < retry; i++ {
            err = txFn()
            if !isDeadLock(err) {
                break
            }
        }
    }

  


  return err
}


func isDeadLock(err error) bool {
    if err == nil {
        return false
    }

    deadlockErr := &ms.MySQLError{Number: 1213}
    if errors.As(err, &deadlockErr) {
        for err != nil {
            err = errors.Unwrap(err)
            if errors.Is(err, deadlockErr) {
                return true
            }
        }
    }

    return false
}

isDeadLock関数を用いてDBで発生したエラーがデッドロックによるものかを判定しています。 DB操作ではデッドロック以外にも様々なエラー(not foundなど)が発生する可能性がある為、この関数がないといつでもリトライしてしまいます。

for文では3回の上限を設け、デッドロック以外のエラーになった場合はbreakしています。

非常にシンプルですが一応実装することはできました。

retry-goを使う

上記でリトライ処理を実装しましたが実務レベルではもう少し複雑な要件が求められます。 例えばリトライ回数を追うごとにリクエストする間隔を増やしたり、全く同じタイミングでリトライされないようにランダムに間隔をずらすなどです。 すべてを自前で実装してもよいのですが、宣言的に簡潔に記述できるretry-goというパッケージを見つけたので使ってみたいと思います。

github.com

以下はretry-goを使った実装です。


func (t *txRepository) WithTx(ctx context.Context, fn func(context.Context) error) error {
    txFn := func() error {
        db := t.db.NewConn(ctx)
        tx := db.Begin()

        ctx = context.WithValue(ctx, &txKey, tx)

        err := fn(ctx)
        if err != nil {
            if dbErr := tx.Rollback().Error; dbErr != nil {
                err = dbErr
            }
        } else {
            if dbErr := tx.Commit().Error; dbErr != nil {
                err = dbErr
            }
        }

        return err
    }


    err := retry.Do(
        // トランザクション関数
        txFn,
        // デッドロックの時のみリトライ
        retry.RetryIf(func(err error) bool {
            return isDeadLock(err)
        }),
        // リトライ回数✖︎1秒づつリクエストを遅らせる
        retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration {
            return time.Duration(n) * time.Second
        }),
        // 最大3回リトライ 初回1+リトライ3
        retry.Attempts(4),
        // サーバー内のerror型で返す
        retry.LastErrorOnly(true),
    )

  return err
}


func isDeadLock(err error) bool {
// ...

上記のretry.Do()の中では以下を行なっています。

  • isDeadLock == trueの時のみリトライする
  • リトライ回数×1秒間ずつリクエスト処理までの時間を待機する
  • 最大3回リトライする 初回1+3 = 4

LastErrorOnly(true)はパッケージ独自のエラー型に依存しないようにしています。

以上とても少ないコード量で複雑なリトライ処理を実装することができました。 他にも便利なコールバック関数が用意されているのでぜひチェックしてみてください。

MySQLにおけるデッドロックとgo, gorm, retry-go, を用いたリトライ処理の実装は以上となります。

まとめ

デッドロックにまつわる仕組みからリトライ処理の実装までを行なってきましたが、いかがでしたでしょうか。 単純にトランザクションを貼っただけでは整合性を担保できなという事からはじまり、ロックの仕組みを調べ、 それにどう対応するか選定していくという一連の流れを行いました。

同じようなパターンで調べると、分離レベルを変更したりテーブルロックをかけるという対応が散見されましたが、 そもそもデッドロックはごく稀に起こるパターンだと思うので、私はその解決の為だけにDBエンジンの設定を変更したり、 特定のビジネスロジックのためにテーブルロックをかけるということをしたくありませんでした。

リトライ処理を実装しておけばDB部分の設定を意識せずデッドロックにも対応できるのではないでしょうか。

今回は以上です。 最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

OAuth, OIDC, Auth0, Cognitoを思い出すINDEX

What

OAuth, OIDC, Auth0, Cognito など認証認可にまつわるプロトコルやIDaaSを調べ直すことが多い。 毎回同じ記事を検索していたのでインデックスとして残しておく。

要約とインデックス

OAuth2.0 OIDC Auth0・Cognito
アクセストークンの要求方法とそれに対する応答方法を標準化したもの

>一番分かりやすい OAuth の説明 - Qiita
『ID トークン』を発行するための仕様

>一番分かりやすい OpenID Connect の説明 - Qiita
IDaaS Auth0,
https://docs.aws.amazon.com/cognito/index.html

OAuth認証 脆弱性

www.sakimura.org

IDaaS比較

OIDCとIDaaS

qiita.com

IDトークンとアクセストークンの違い

yyh-gl.github.io

ハンズオン

qiita.com

Go言語の振り返り

Go言語を使い出してもうすぐ一年が経ちます。

この機会にGo言語の振り返りをしたいと思います。

Go言語のメリット

Goを学習して感じたメリット

  • 可読性が高い
    • gofmtが世界標準になっていて、プロジェクト毎に設定する必要がない
  • 標準パッケージが充実している
    • 標準パッケージでできる限りやるというのがgopherの思想になっているので技術選定が楽
  • 高速
  • シングルバイナリ
    • デプロイが楽
  • 後方互換性が保証されている
    • バージョンアップが楽
  • 並行処理が標準実装されている
  • 開発ツールが豊富(godoc, gofmt, go generate, go test, go vet)
  • OSSコードリーティングしやすい
    • 誰が書いても同じようなコードになる為OSSが読みやすい
  • エラーハンドリングが分かり易い
  • 依存パッケージのimportが最小限
    • 速い

筆者は静的言語もGoが初めてだったので、その学びも多くあった。

  • 型安全
  • エディタでの補完機能の享受
  • interfaceあり DI可能, MVC以外のアーキテクチャの知見を得られる
  • 最低限の低レイヤーへの知見を得られる

OSSコードリーディング

業務で扱う比較的大きめのパッケージは自然と読みに行く事も増えた。

SDKは特に読みに行く事が多い

tips

よく使う方法

  • vscodeのデバッカー
  • githubページで .を押してエディタを出す
  • godocから辿る

なぜ読むのか?

  • エラーがパッケージによって起きているか判別する為
  • 学び・有名OSSがどうやってGoを書いているか分かる
  • 他人が書いたコードを読む事に慣れる == キャッチアップが早くなる

チャネルとゴルーチン

本業のプロジェクトのチャットシステムや状態管理でRedisのPub/Sub 使用。

これをGoで実装する時 ゴルーチンとチャネルを使用した。

 go func() {
        pubsub := m.Client.PSubscribe(ctx, "__keyspace@0__:*")
        defer pubsub.Close()

        ch := pubsub.Channel()

        for {
            select {
            case <-ctx.Done():
            case msg := <-ch:
                switch msg.Payload {
                case "set":
       ...
}

パフォーマンス改善ではまだ使ったことがない。 個人アプリで定期実行の為に使った事がある。

設計

これまで携わったGoプロジェクトでのアーキテクチャは以下

チャットボット+web通話システム

GraphQLなのでResolverがController的な役割になる。 一応レイヤードアーキテクチャにしていたが、ところどころ依存関係が崩れていたり、usecaseが省かれてた。特に大きなバグとかもなかったが、属人性は高くなっていた印象。もう少しアーキテクチャを固くしてもいいと思っている。

toB SaaS (β版ではSaaSの認証システムを提供している)

usecase部分のinput, output構造体は省いた。 そこまで固くしてもあまりメリットがなかった為。 構造体はポインタ返し、プリミティブな方は実体返し、usecaseは必ず作る。楽さと固さを両立できていていい方針だと思う。

これまでに読んだ書籍や教材

書籍

動画

ネット教材

同人本

フォローしているGopherの方々

まとめ

この1年でGo言語を軸に色々な事を勉強しました。 つい昨日 Go1.18がリリースされたので早速ダウンロードして触ってみたいと思います。

Single Source of Truth 信頼できる唯一の情報源 SSOT

今回は SSOTの考え方についての学習録です。

SSOTとは

Wikipedia

情報システムの設計と理論においては、すべてのデータが1か所でのみ作成、あるいは編集されるように、情報モデルと関連するデータスキーマとを構造化する方法である。データへのリンクで可能なものは参照のみである。プライマリ以外の他の場所のすべてのデータは、プライマリの「信頼できる情報源」の場所を参照するだけであり、プライマリのデータが更新された場合、どこかで重複したり失われたりすることなく、システム全体に伝播される。

SSOTとは Single Source of Truthの略で唯一の信頼できる情報源という意味です。

いろんな文脈で語られていますが、webアプリケーション分野ではアーキテクチャの中で複数のデータストレージが使われている場合、利用者にあたかも一つのソースであるかのようにアクセスさせる事です。

具体的には

具体的な例を紹介します。

例えばあるアプリケーションサーバーでMySQLにユーザー情報を保存して、認証認可とIDPにCognitoを使用していたとします。

一般的な設計ではMySQLのユーザーとCognitoのユーザーは一対一である必要があります。 Cognitoを正とした場合、ユーザーのCRUDな処理では常にCognitoとMySQLのユーザー情報に整合性が取れるように処理しなければなりません。たとえばユーザーを作成してMySQLへのinsertは成功したけどCognitoに対する create user は失敗するというようなことは起ってはいけません。

アプリケーションの実装

RDBの場合はBegin, RollBack, Commit, のような任意でトランザクションを開始、破棄、終了できるようなAPIが用意されています。これはほとんどのORMにも実装されているので、コードを実装する時にも Cognitoの処理に失敗した場合、RDBの処理もRollBackするという事ができます。

ではデータストアにそういったトランザクションAPIが用意されていない場合はどうでしょうか? 例えばDynamoDBには書込みトランザクションと読み込みトランザクションがありますが失敗した時には自動でロールバックされるようになっています。RDBのようにRollBackを使ってCognitoとの整合性を保つことはできません。

この場合は別途バッチ処理を走らせてCognitoと整合性を保つような実装をする必要があります。

まとめ

RDBトランザクションは便利ですが、今までDB外のものと整合性を取る為にも使用していました。

厳密にはRDBの処理はRDB内の変更に抑えるべきで、それより外側は責任の範囲外になります。 これを機にSSOTという考え方を覚えておきたいと思います。

今回は以上です。 最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

GraphQL vs OpenAPI スキーマ駆動開発比較

今回はGraphQLとOpenAPIの開発を比較していきたいと思います。 筆者は本業のプロジェクトでGraphQLを、副業でOpenAPIを使用しています。

アプリケーション開発時にどちらを選定するか迷っている方の参考になれば幸いです。

使用技術概要

GraphQL OpenAPI
バックエンド Go Go
フロントエンド Next.js Next.js
バックエンドコードジェネレーター gqlgen oapi-codegen
フロントエンドコードジェネレーター graphql-codegen openapi-generator
状態、キャッシュ管理 ApolloClient Redux(予定)
DB DynamoDB MySQL
使用期間 8ヶ月 3ヶ月

プロジェクトはいずれも新規開発(SaaS)でモノリスアーキテクチャです。gRPCも比較対象としてよく挙げられますが未経験の為触れません。 SPAのアプリケーション開発という視点で比較したいと思います。


GraphQL vs OpenAPI

結論から言うと私はGraphQL推しです。特にフロントエンド開発においてはかなりメリットがある印象です。

まずフロントエンド開発においてGraphQLに軍配が上がる理由はApolloClientRelayといった強力なライブラリがある事です。(Relayは未経験)

ApolloClientはライブラリ内にキャッシュ管理機構、GraphQLスキーマからのhooks自動生成(ジェネレータ)など強力な機能がたくさんあります。コールバック関数も豊富で自前実装する箇所はとても少ないです。Redux使うかどうかという議論なしに丸っとApolloClientに丸投げできます。またGraphQLなら複雑なデータも一つのリクエストで取得することができます。

OpenAPIにもコードジェネレータはありますが、APIをfetchするコード部分のみです。TypeScriptの型が使えたり、axiosかfetchAPIを選べたりしますがあくまでも最低限の機能という感じです。

以上の理由からフロントエンド開発者としてはGraphQLを推したくなります。

一方でGraphQLにはいくつか問題があります。まずは学習コストがそれなりに高いです。OpenAPIはRESTfulさえ理解できればスキーマ定義(yaml)はそこまで難しくない印象があります。GraphQLはというととにかく新しい用語が多いです。(Mutation,Resolver, Fragmentsなどなど) また、OpenAPIに比べてバックエンド側のコード量は多くなる傾向にあると思います。柔軟にAPIを生やすことができるので極端に言うとどんなリソースでも無限に(簡単に)表現できてしまいます。RESTfulというプラクティスがあるのに対しGraphQLのAPI設計はこうあるべきという指標はまだ少く、設計をちゃんとしないと同じようなAPIがたくさん生えたり、あとから実装しようと思って定義していたAPIを結局使わなかったという事があります。

ざっくりですが以上が両者の比較です。

結局どちらを採用したいかでいうと、リッチなUIが求められる程GraphQLの方がメリットが多のではないかと思います。ApolloClientはフロントエンド開発者にとって学習コストに対して余りある恩恵が得られると思っています。比較的にシンプルなWebサービスAPI単体で提供する場合にはOpenAPIの方が良いと思います。

スキーマ駆動開発比較

次に前章でも軽く触れましたが、両プロジェクトで使ったコードジェネレーターやクライアントツールを元に開発フローを比較紹介したいと思います。

GraphQL

クライアントツール

ドキュメントGUI

  • gqlgenのplayground

GoでGraphQLサーバーを立てる時はgqlgenというパッケージを使用しています。

大まかな開発フローは以下です。

バックエンド

  • .graphql ファイルにスキーマ定義
  • cliでコード生成
  • 生成されたResolverを実装する。

フロントエンド

  • codegen.yml に設定を記述(Apolloとの連携)
  • cliでコード生成
  • UI実装

gqlgenにはplaygroundという機能があり、エンドポイントを指定するとlocalhostGUIを起動してAPIを試すことができます。 このプロジェクトはフロント, バックエンド一貫して開発しているのでmockサーバーは立てていませんが、graphql-fakerというライブラリがあるようです。

OpenAPI

次にOpenAPIです。

ジェネレーター

クライアントツール

大まかな開発フロー。

バックエンド

  • .yaml ファイルにスキーマ定義
  • cliでコード生成
  • 生成されたControllerを実装する。

フロントエンド

  • cliでコード生成
  • UI実装

こちらのプロジェクトではフロントチームとサーバーチームで別れて開発をしているのでprismでモックサーバーを立てています。クライアントツールは個人的にはvscodeを使っていますが、他にもたくさんあるので各々好きなものを使っている感じです。


以上が両者の開発フローです。

今のところ大きく異なる点は無いです。gqlgenではスキーマファイルの分割とコード生成の設定が複雑だったりしますが、OpenAPIにもかゆいところに手が届かないという事はよくあるので細かい設定でのメリデメは相殺されると思います。

GraphQLのplaygroundはQuery, MutationのAPIを叩くまであまり慣れませんでしたが、スキーマからドキュメントを生成してくれたり、argsを変数で設定できたりと機能自体はOpenAPIのクライアントツールよりリッチかもしれません。

スキーマ開発のメリットとしては、ドキュメント管理のコスト削減、コード(型)の自動生成、フロント・バックエンドの開発速度向上が挙げられると思いますが両者とも同じ恩恵を享受できると考えていいと思います。

まとめ

これまでの開発で使用した技術を比較してきましたが、もし筆者が0から個人開発をするとしたらGraphQLを採用したいというのが本音です。しかしチーム開発となるとフロント, バックいずれもかなり学習コストがかかります。それを許容できるのであればGraphQLの導入を一度考えてもいいかもしれません。

今回は以上です。 最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

DaynamoDB を学習した時の記事、詰まった箇所

2022年の1月から副業業務委託としてtoB SaaSの開発をお手伝いさせて頂いています。

以下の技術スタックで開発してます。

  • Go
  • DynamoDB
  • OpenAPI3

DynamoDB, OpenAPIは初めて触る技術でした。 新しい技術のキャッチアップはかなり負荷のかかる学習ですが、同時に1番楽しいフェーズでもあります。

今回はDynamoDBのキャッチアップで参考になった記事、詰まった箇所を記しておきたいと思います。

参考記事一覧

Future Tech Blog

フューチャーさんのテックブログです。

大変お世話になりました。 DaynamoDBの概要から、Go×DaynamoDBライブラリの使用例まで一通り網羅できます。


入門記事

DaynamoDB✖︎Goのライブラリユースケース


公式Doc

公式Docです。 https://docs.aws.amazon.com/ja_jp/dynamodb/?pg=dynamodbt&sec=hs

主に参考にした記事は以上です。

ネット上にDynamoの記事や教材はまだまだ少ない印象でした。 体系的に学ぶには公式Docが1番良さそうです。

DaynamoDB扱う上で詰まった箇所

実際に開発していて最初詰まった箇所は以下のような感じです。

※DaynamoDBとはなにか?RDBとの違いは?というような記事はたくさんあるのでそういった話は割愛します。

  • データ定義はjsonファイル
  • 操作はAPI経由
  • AWSクレデンシャルで接続する
  • GUIはNoSQL WorkBenchを使う(AWSコンソールからもアクセスできる)
  • 開発環境はDynamoDB-localを使う
  • シングルテーブルに寄せる

順番に説明していきます。

データ定義はjsonファイル

RDBだとマイグレーションファイルを用意してDDLでテーブル定義すると思いますが、 DaynamoDBはjsonファイルを用意する事でテーブルやデータ型を定義できます。

tabledefinition.json

{
    "Artist": {
        "AttributeValueList": [
            {
                "S": "No One You Know"
            }
        ],
        "ComparisonOperator": "EQ"
    },
    "SongTitle": {
        "AttributeValueList": [
            {
                "S": "Call Me Today"
            }
        ],
        "ComparisonOperator": "EQ"
    }
}
$ aws dynamodb create-table --cli-input-json file://tabledefinition.json

操作はAPI経由, AWSクレデンシャルで接続する

DaynamoDBへの指示は基本的にAPI経由になります。

aws cli でcredentialを見て対象のDBを操作する感じですね。

// アイテム(レコード)作成
$ aws dynamodb put-item --table-name {tableName} --item {item}

// スキャン
$ aws dynamodb scan --table-name {tableName}

// アイテム削除
$ aws dynamodb delete-item --table-name {tableName} --key {key}

RDBではデーターベースサーバーに入ってDB自体を操作しているイメージなので aws cli でDBを操作するのは違和感がありました。

cliを叩く時にawsのcredentialをしっかり設定しないと、期待したAWSアカウントを見にいってくれません。 オプションで毎回credentialを指定する事もできますが、アカウント毎に作業ディレクトリ用意するのが個人的には楽に感じました。

GUIはNoSQL WorkBenchを使う(AWSコンソールからもアクセスできる)

参画しているチームでは積極的にNoSQL WorkBenchというGUIを使っています。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/workbench.settingup.html

画面をポチポチするだけでデータが見れたり、SQLのようにAPIが叩けたりします。

jsonファイルに定義してあるテーブルの取り込みもこのツールで行なっています。

ただバグが多く、学習コストも微妙にある印象でした。 ちなみにAWSのマネジメントコンソールからでも似たようなGUIがあります。

今のところAPIよりはGUIで触った方が開発体験が良いです。

開発環境はDynamoDB-localを使う

開発時はDaynamoDB-localを使っています。 dockerでの構築が非常に簡単で前述したGUIとも容易に接続できます。

まだ実践していませんが、複数AWSアカウントへの接続を再現できればもっと良いなと思っています。

docker-compose.yaml

services:
  dynamodb-local:
    container_name: test_dynamodb-local
    image: amazon/dynamodb-local:latest
    user: root
    command: -jar DynamoDBLocal.jar -sharedDb -dbPath /data
    volumes:
      - dynamodb-local-data:/data
    ports:
      - 8000:8000

シングルテーブルに寄せる

設計方針はかなりRDBと異なります。 基本的にDaynamoDBはテーブルを正規化せずにシングルテーブルに寄せていくのが良いとされているようです。

※複数テーブルにまたがるtransactionも出来ない

DaynamoDBに慣れているエンジニアがほとんどおらず、どうしてもRDBを意識してしまうのが現状の課題です。

今回は以上です。 最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。

goldie でAPIテスト Go Golang goldie その1

今回はgoldieというライブラリを使ったGolden testを実装していきます。

Golden Test

Goleden Testとは .golden拡張子のファイルにAPIの結果(jsonなどを)保持しておき、テスト時に出力結果に変更がないかを比較するテスト手法です。

goldie

goldieはGo言語用の Golden Test ライブラリです。 .goldenファイルの生成と比較をしてくれます。

github.com

一般的にテストファイルと.goldenファイルは以下のような構成になります。

.
├── main.go
├── test
│   ├── testdata
│   │   └── TestUsers.golden
│   └── user_test.go

APIテスト

Golden Testと goldieについて理解したところでテストを作成していきます。

テストするのはuserを扱う単純なAPIです。

Method URL 概要
GET /users 全てのユーザーを取得
POST /users ユーザーを作成

まずは上記のうち /usersのテストを行います。

以下がテストコードです。 *FWやDBに依存しているところがありますが見逃してください。

package test_integration

import (
    ...省略

    "github.com/sebdah/goldie/v2"
)

func setUp(t *testing.T) *gin.Engine {
    // TODO change by env
    conn, err := sql.Open("mysql", "root:@tcp("+os.Getenv("DB_HOST")+":3306)/"+"DB_NAME")
    if err != nil {
        t.Fatalf(err.Error())
    }
    tx, err := conn.Begin()
    if err != nil {
        t.Fatalf(err.Error())
    }

    infraTx := infrastructure.Tx{
        Tx: tx,
    }

    userController := controllers.NewUserController(infraTx)
    _, err = userController.Interactor.UserRepository.Store(domain.User{
        ID:        0,
        FirstName: "firstName",
        LastName:  "lastName",
    })
    if err != nil {
        t.Fatalf(err.Error())
    }

    r := infrastructure.NewRouter(userController)
    t.Cleanup(func() {
        if err := infraTx.RollBack(); err != nil {
            t.Fatalf("failed RollBack")
        }
    })
    return r
}

func TestUsers(t *testing.T) {
    r := setUp(t)
    api := "/users"
    req := httptest.NewRequest(http.MethodGet, api, nil)
    rec := httptest.NewRecorder()
    r.ServeHTTP(rec, req)
    if rec.Code != http.StatusOK {
        t.Fatal(rec)
    }
    ret := regexp.MustCompile(`\"id\":[0-9]+`).ReplaceAllString(rec.Body.String(), `"id":0`)
    g := goldie.New(t)
    g.AssertJson(t, t.Name(), ret)
}

順に解説していきます。

setUp()

setUp()関数ではテスト用のDBへの接続とダミーデータの生成を行ない ルーターの構造体(*gin.Engine)を返しています。

テストをトランザクション内で実行している事に注目してください。

    // トランザクション開始
    tx, err := conn.Begin()

    // user生成
    _, err = userController.Interactor.UserRepository.Store(domain.User{
        ID:        0,
        FirstName: "firstName",
        LastName:  "lastName",
    })

    // 終了後にロールバック
    t.Cleanup(func() {
        if err := infraTx.RollBack(); err != nil {
            t.Fatalf("failed RollBack")
        }
    })

テスト毎にロールバックする事で毎回DBを同じ状態からテストする事ができます。

また、本来なら ginmysqlに依存しない様に独自の構造体やパッケージに分けるべきですが今回は割愛します。

TestUsers()

つぎに実際のテストコードを見ていきます。

以下のコードでAPIを叩いてレスポンスを取得しています。

   api := "/users"
    req := httptest.NewRequest(http.MethodGet, api, nil)
    rec := httptest.NewRecorder()
    r.ServeHTTP(rec, req)
    if rec.Code != http.StatusOK {
        t.Fatal(rec)
    }

次に regexp パッケージを使用して id0に書き換えています。 これにより取得したすべてのuserのidは "id":0 となります。 これは idカラムはオートインクリメントになっており、番号が保証されていないからです。

   ret := regexp.MustCompile(`\"id\":[0-9]+`).ReplaceAllString(rec.Body.String(), `"id":0`)

最後に goldie パッケージを使用して比較を行います。

   g := goldie.New(t)
    g.AssertJson(t, t.Name(), ret)

以上がコードの解説となります。

テストの実行

Golden テストの実行は2段階のコマンドにより行われます。

まずは -update フラグを使用して.goldenファイルを更新します。

go test -v -run ${TARGET} ./test/... -update

これにより、{テスト名}.golden ファイルにAPIのレスポンスが書き出されます。

[{\"id\":0,\"first_name\":\"firstName\",\"last_name\":\"lastName\",\"full_name\":\"firstName lastName\"}]"

setUp() 関数で生成したuserを取得できました。

以後テストしたい時は -updateフラグを外す事で既存の .goldenファイルと比較を行い、結果が.goldenファイルと異なるか.goldenファイル自体が見つからない場合はエラーを返します。

go test -v ./test/...

以上が goldieを用いたGolden testの流れとなります。 今回はシンプルなGETのAPIを叩いただけでしたが、次回はもう少し実務ベースのテストとgoldenファイルのテクニックを紹介したいと思います。

最後に、 このブログではweb開発について発信していくのでまたご覧頂けると嬉しいです。 最後までお読み頂きありがとうございました。