🐱

GoのWebアプリケーションでトランザクション処理をスッキリ書く

に公開

はじめに

この記事ではバックエンドエンジニアである筆者が、Transaction処理をクリーンアーキテクチャでどう実装すべきかに悩みに悩み、結果的に現場で採用した書き方をご紹介します。
内容としては、Goで実際に現場でアプリケーションを構築している方向けかもしれません。
※ ORMにはgormを使用しています。

TL;DR

  • WithTransaction を使うとトランザクション管理がシンプルになる
  • Tx を ctx に埋め込むことで Commit、Rollbackを気にする必要がなくなる

背景

トランザクションをどう実装すべきか

クリーンアーキテクチャを学び始めた時の頃、トランザクションの処理をどう実装するべきなのかがわからず、悪戦苦闘することがありました。クリーンアーキテクチャはデータの取得とビジネスロジックを分けるところがポイントだと思いますが、これを素直に実現しようとすると、

[擬似コード]
// データを取得する(repository層)
user := get()
// データを書き換える(service層)
user.Name = "hoge"
// データを更新する(repository層)
update(user)

となると思います。ただ、これだと、複数テーブルを更新する際に、どうやってアトミック性を担保すればいいのかがずっと疑問でした。repository層はテーブルと1対1で対応させていたので、テーブルAからデータ取得、テーブルBでデータ取得、Aを更新、Bを更新時にエラー、となると当然ロールバックする必要があります。その際の書き方が明確になっていないことが多く、「どうするんだろう?」と思っていました。

func (u *userUsecase) UpdateUserName(ctx context.Context, body req.User) (*res.User, error) {
    user, err := u.userRepo.GetUser(ctx, body.UserID, tx)
    if err != nil {
        return nil, err
    }
    
    user.Name = body.Name
    _, err = u.userRepo.UpdateUser(ctx, user, tx)
    if err != nil {
        return nil, err
    }

    userDetail, err := u.userRepo.GetUserDetail(ctx, user.UserID, tx)
    if err != nil {
        return nil, err
    }
    userDetail.Address = body.Address
    _, err = u.userRepo.UpdateUserDetail(ctx, userDetail, tx) // <- 「ここでエラーになった時どうするの?」が駆け出しの頃の悩みでした。
    if err != nil {
        return nil, err
    }

    return res.GetUser(user), nil
}

repository層の構造体で複数のテーブルを紐づける

1対1をやめ、repository層で複数のテーブルを更新する方針もやりましたが、ビジネスロジックがrepository層にまでかなり侵食してしまうため、単体テストがしにくくなり、あまりよくないなとも思いました。実際、そのように作ったrepository層のメソッドはあまり再利用性が高くありませんでした。

type BaseRepository interface {
    // トランザクション開始時にBeginTransactionを呼び出す。
	BeginTransaction() driver.Transaction
}

type baseRepository struct {
	dbDriver driver.DBDriver
}

func (r *baseRepository) BeginTransaction() driver.Transaction {
	return r.dbDriver.BeginTransaction()
}

type Transaction interface {
	Commit() error
	Rollback()
	GetTx() *gorm.DB
}

type Tx struct {
	tx *gorm.DB
}
func (t Tx) Commit() error { return t.tx.Commit().Error }
func (t Tx) Rollback() { t.tx.Rollback() }
func (t Tx) GetTx() *gorm.DB { return t.tx }

type UserRepository interface {
    // 複数のmodelをenityにまとめて、repositoryに渡して更新した。
    // 特定の用途にしか使われない、再利用性の低いメソッドが乱立してしまった。
    UpdateUserData(ctx context.Context, user *entity.User) (*entity.User, error)
}

Txのあるなしを用意する形式

トランザクションの処理をどう書けばいいかわからないまま、あるプロジェクトでは、begin()メソッドを用意し、そこでトランザクションを変数txに詰めて、そのtxをrepository層の描くメソッドの引数で使い回すという方式がありました。よって、repository層はTxのあるのとないのとで2パターンメソッドを書くというものでした。

type UserRepository interface {
    UpdateUser(ctx context.Context, user *model.User) (*model.User, error)
    UpdateUserTx(ctx context.Context, user *model.User, tx driver.Transaction) (*model.User, error)
    /*
    Get
    GetTx
    Create
    CreateTx
    と二つずつ書く
    */
}

この方式だと、処理が明瞭ではあるものの、必ずDBにアクセスする処理で、CreateやUpdateなどそれぞれ二つずつメソッドを書く必要がありました。さらにservice層に処理をまとめようとすると、その中でもTxでやるかどうかを考慮して処理を書かなければならず、あまり開発効率がよくありませんでした。

また、txのcommit漏れ、rollback漏れが多くあり、レビューでの指摘が多くありました。妥協案として、tx.Rollback()をdeferで呼び出す方式です。「Commitした後でも呼ばれてはしまうものの、それは特に影響はないだろう」、という方法にしました。

func (u *userUsecase) UpdateUserName(ctx context.Context, body req.User) (*res.User, error) {
    tx := u.baseRepo.BeginTransaction()
    user, err := u.userRepo.GetUserTx(ctx, body.UserID, tx)
    if err != nil {
        tx.Rollback() // <= 書き忘れがち
        return nil, err
    }
    
    user.Name = body.Name
    _, err = u.userRepo.UpdateUserTx(ctx, user, tx)
    if err != nil {
        tx.Rollback() // <= 書き忘れがち
        return nil, err
    }
    
    err = tx.Commit()
    if err != nil {
        tx.Rollback() // <= 書き忘れがち
        return nil, err
    }
    return res.GetUser(user), nil
}
tx := u.baseRepo.BeginTransaction()
defer tx.Rollback() // <- Commit()成功後に呼び出すべきではないがそれは妥協。
user, err := u.userRepo.GetUserTx(ctx, userID, tx)
if err != nil {
    return nil, err
}

user.Name = body.Name
resp, err := u.userRepo.UpdateUserTx(ctx, user, tx)
if err != nil {
    return nil, err
}
err := tx.Commit()
if err != nil {
    return nil, err
}

Txは最後の引数にする

一時は、txを引数の最後に必ず渡すようにし、dbの実行直前にnilかどうかをチェックして実行するという方法も試しました。明瞭さと引き換えにメソッドの数を減らせたものの、先ほどと比べて特段メリットがあるわけではありません。それにこれだと今度は、txを渡し忘れたりするリスクが出てきます。

type UserRepository interface {
    UpdateUser(ctx context.Context, user *model.User, tx driver.Transaction) (*model.User, error)
}

func (d *dbDriver) Create(ctx context.Context, model interface{}, tx driver.Transaction) error {
	if isNil(tx) { // nilであれば通常実行
        result := db.Create(model)
        if result.Error != nil {
            return result.Error
        }
    } else { // nilでなければトランザクションで実行
        result := tx.Create(model)
        if result.Error != nil {
            return result.Error
        }
    }
	return nil
}

func isNil(i interface{}) bool {
	if i == nil {
		return true
	}
	v := reflect.ValueOf(i)
	if v.Kind() == reflect.Ptr {
		return v.IsNil()
	}
	return false
}

Goで悪戦苦闘する一方、railsにはactive recordにて、Transactionメソッドがあります。以前、railsのプロジェクトにいたこともあり、この方式が一番やりやすいなと思っていました。Goでも同じようにできれば楽になるのになと。

// railsだと直感的に書ける
ActiveRecord::Base.transaction do
  david.withdrawal(100)
  mary.deposit(100)
end

採用したトランザクション処理の実装

WithTransaction

いろいろ模索した結果、このやり方が今のところ一番やりやすいかなと思うのをご紹介します。
先ほどのbaseRepositoryに「Transactionを一元管理するWithTransactionメソッド」を追加します。

type BaseRepository interface {
    BeginTransaction() driver.Transaction
    WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error // (追加)
}

type baseRepository struct {
    dbDriver driver.DBDriver
}

// WithTransaction トランザクション内で関数を実行する
func (r *baseRepository) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) (err error) {
    
    // ネストされたWithTransactionには対応しないようにします。
    if _, ok := ctx.Value(driver.TxKey{}).(driver.Transaction); ok {
        return fmt.Errorf("transaction already exists")
    }

    // それぞれで呼んでいたBeginTransactionをここで呼び出します。
    tx := r.dbDriver.BeginTransaction() 
    
    defer func() {
        if rec := recover(); rec != nil {
            // panicが発生した場合も必ずロールバックします。
            tx.Rollback()
            err = fmt.Errorf("panic recovered in transaction: %v", rec)
        } else if err != nil {
            // 通常のエラーの場合もロールバックも書きます。
            tx.Rollback()
        } else {
            // 正常終了時のコミットです。
            if commitErr := tx.Commit(); commitErr != nil {
                tx.Rollback()
                err = fmt.Errorf("failed to commit transaction: %w", commitErr)
            }
        }
    }()
    
    // コンテキストにトランザクションを埋め込んで実行します。
    // ここがポイントで、txを引数で渡すのではなく、ctxに入れることで自然に伝播するようにしています。
    ctxWithTx := context.WithValue(ctx, driver.TxKey{}, tx)
    return fn(ctxWithTx)
}

Transaction処理をしたいときにWithTransactionを呼び、アトミック操作の処理を関数で渡します。WithTransactionの内部処理ですが、r.dbDriver.BeginTransaction() で受け取ったDBコネクションをctxに詰め、deferでCommitやRollbackの処理を書きます。
ここに処理を集約することで、Rollback漏れなどをなくすことができます。

getTransactionFromContext

そして、transactionで実行するか否かはinfrastruture層に任せます。


type TxKey struct {} // ctxからtxを取り出すためのキー

// getTransactionFromContext contextからトランザクションを取得する
func getTransactionFromContext(ctx context.Context) Transaction {
	if tx, ok := ctx.Value(TxKey{}).(Transaction); ok {
		return tx
	}
	return nil
}

// getDBFromContext contextの内容に応じて適切なDB接続を返します。
func (d *dbDriver) getDBFromContext(ctx context.Context) *gorm.DB {
    // ここの分岐で、contextにtxが入っていたらトランザクション処理で、なかった場合は通常の処理になります。
	if tx := getTransactionFromContext(ctx); tx != nil {
		return tx.GetTx()
	}
	return d.writeClient // dbWriteClient *gorm.DB
}

func (d *dbDriver) Create(ctx context.Context, model interface{}) error {
	db := d.getDBFromContext(ctx) // ctxの値を見て、その処理をトランザクションで実行するかを判断する。
	result := db.Create(model)
	if result.Error != nil {
		return result.Error
	}
	return nil
}

呼び出し方

トランザクションで処理を呼び出したいときは以下のようになります。

func (u *userUsecase) UpdateUserName(ctx context.Context, body req.User) (*res.User, error) {
    var resp *model.User
    err := u.baseRepo.WithTransaction(ctx, func(ctx context.Context) error {
        user, err := u.userRepo.GetUser(ctx, body.UserID) // Txあるなし、でメソッドを複数用意する必要がなくなる
        if err != nil {
            return err
        }
        user.Name = body.Name
        resp, err = u.userRepo.UpdateUser(ctx, user)
        if err != nil {
            return err
        }
        // tx.Commit(), tx.Rollback()を気にする必要がなくなる
        return nil
    })
    if err != nil {
        return nil, err
    }
    return res.GetUser(resp), nil
}

一番のメリットはTxのあるなしでメソッドを用意する必要がなくなり、コードの量は削減され、見通しの良いコードが書けるようになります。
Txを引数で渡さない変わりに、ctxに埋め込めば自然に伝播するので、今後さらにレイヤーを追加してもTxを意識せずに済みます。

従来の方法と比較するとこうなります。

Before

func (u *userUsecase) UpdateUserName(ctx context.Context, body req.User) (*res.User, error) {
    tx := u.baseRepo.BeginTransaction()
    
    // BeforeではTx付きを用意し、それを呼び出す必要がありました。
    user, err := u.userRepo.GetUserTx(ctx, body.UserID, tx)
    if err != nil {
        tx.Rollback()
        return nil, err
    }
    user.Name = body.Name
    resp, err := u.userRepo.UpdateUserTx(ctx, user, tx)
    if err != nil {
        tx.Rollback()
        return nil, err
    }
    if err := tx.Commit(); err != nil {
        tx.Rollback()
        return nil, err
    }
    return res.GetUser(resp), nil
}

After

func (u *userUsecase) UpdateUserName(ctx context.Context, body req.User) (*res.User, error) {
    var resp *model.User
    err := u.baseRepo.WithTransaction(ctx, func(ctx context.Context) error {
        user, err := u.userRepo.GetUser(ctx, body.UserID) // Txなしで統一
        if err != nil {
            return err
        }
        user.Name = body.Name
        resp, err = u.userRepo.UpdateUser(ctx, user) // Txなしで統一
        if err != nil {
            return err
        }
        return nil
    })
    if err != nil {
        return nil, err
    }
    return res.GetUser(resp), nil
}

比較表

従来の方法 WithTransaction パターン
UpdateUser と UpdateUserTx のように、各DB処理につき2メソッド必要 Updateは1メソッドで済む
tx.Commit(), tx.Rollback() を漏れなく書く必要がある WithTransaction 内で完結するため気にする必要がない
service層を作る時に Tx あり/なしを意識して実装する必要あり ctx を渡すだけで良い

課題点

分散トランザクションへの対応は別途必要

小規模~中規模システムで、かつ外部APIとの連携処理が複数ない場合はこれでカバーできますが、そうでない場合は別途対応が必要です。outboxパターン(外部システムとの整合性を保つ設計パターン)を導入するなどの対応が必要です。

ネストが深くなること

また、ネストが深くなって可読性が損なわれないようにケアするのも必要です。今の時点ではネストの呼び出しが行われたらエラーを返すことで意図しない呼び方を防いでいます。

context汚染の懸念

一番議論を呼ぶのが、ctxにtxを詰めているので、「context汚染ではないか」という点です。これに関しては、リクエストスコープ値の受け渡しという、contextの守備範囲内だろうと判断しています。この辺りはご意見承ります。

終わりに

いろいろ模索した結果、現時点でWithTransactionが一番しっくりする手法でした。
repositoryのソースコードは従来の半分になり、txのあるなしを考慮することなく、service層で処理を拡充したりできました。
tx.Rollback等を気にせず、ビジネスロジックの実装に集中できるようになりました。
何か開発のヒントになれば幸いです。

GitHubで編集を提案

Discussion