🫣

GoのBunでtransactionをusecaseに型漏出せずにコントロールしたい

2024/10/11に公開

トランザクション制御って、スマートな書き方難しいこと多くないでしょうか。
なんだかいつも悩んでる気がしますね。
先日悩んだので、あまり納得の行く実装ではないのですが、ここに記しておこうと思います。

最終的な実装

例によって例のごとく先に最終的に行った実装を残しておきます。
他に試した実装は後述しています。人によってはそちらのほうが気にいるかも...?

// infrastructureのrepositoryなどにDIするDB接続用の人
package bun

var handler *bun.DB
var once sync.Once

type Handler struct {
	db *bun.DB
}

func NewHandler(cfg config.Postgres) *Handler {
	once.Do(func() {
		sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(getDSN(cfg))))

		handler = bun.NewDB(sqldb, pgdialect.New())
	})

	return &Handler{
		db: handler,
	}
}

// INFO: reader, writerみたいなメソッドも生やせるので、そのときにも使えるはず
func (h *Handler) DB(ctx context.Context) bun.IDB {
	tx := ctx.Value(key)
	if tx != nil {
		return tx.(bun.IDB)
	}
	return h.db
}
// トランザクションの境界を作るための抽象
package persistence

import "context"

type Transaction interface {
	Exec(context.Context, func(ctx context.Context) error) error
}
// トランザクションの境界を作る実態
package bun
var key = &txKey{}

type txKey struct{}
type Transaction struct {
	db *Handler
}

func NewTransaction(
	db *Handler,
) *Transaction {
	return &Transaction{
		db: db,
	}
}

func (t *Transaction) Exec(ctx context.Context, f func(ctx context.Context) error) error {
	return t.db.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
		context.WithValue(ctx, key, &tx)
		err := f(ctx)
		context.WithValue(ctx, key, nil)
		return err
	})
}
// ストレージに対して処理を行うrepositoryの実態
package repositoryimpl

var _ repository.HogeRepository = (*HogeRepository)(nil)

type HogeRepository struct {
	handler *bun.Handler
}

func (r HogeRepository) FindByID(ctx context.Context, id identifier.HogeID) (*model.Hoge, error) {
	var t table.Hoge

	if err := r.handler.DB(ctx).NewSelect().
		Model(&t).
		Where("id = ?", id.UUID()).
		Scan(ctx); err != nil {
		return nil, errors.Wrap(err)
	}

	return convert.ToP(toHogeModel(t)), nil
}
// transactionのコントロールを行うusecase
type FugaUseCase struct {
	service     service.FugaDomainService
	transaction persistence.Transaction
}

func (u FugaUseCase) Do(ctx context.Context, in FugaUseCaseInput) error {
	return u.transaction.Exec(ctx, func(ctx context.Context) error {
		err := u.service.Execute(ctx, in.UserID, in.DiaryID)
		if err != nil {
			return errors.Wrap(err)
		}

		return nil
	})
}

この実装に対する所感

良い点

  • Repositoryの実装をtransactionを意識せずに素直に書くことができる
  • 任意のレイヤーでトランザクションをコントロールできる
  • domain(core)に型情報などが流出してない

悪い点

  • contextの使い方として結構正当性に疑問が残る
  • (おそらく)goroutineと併用すると壊れる
  • handler, transactionのstructがちょっとお互いを意識しており結合してる
  • transactionのoptionのことは何も考えてない

感想

私はgoroutineは、それがないと困るという状況にならない限りは使わないスタンスで(個人開発は)開発しているので、普通に利用してるぶんには書き心地自体は悪くないなぁと思いました。
ただcontextを「便利な箱」として利用している部分に関しては、苦肉の策だなぁ感が否めません。

リクエストスコープ内で管理したいものを入れる、みたいなものがあるとおもうのですがトランザクションの境界ってリクエストスコープなのかなぁ...と思います。
基本的にそれで問題ないことが多いでしょうけど、場合によっては1つの処理の中でトランザクションのスコープは複数に分割したいこともあると思うので結構苦しいという気持ちです。

handler, transactionがお互いをチラ見しているのは、そもそもhandlerもtransactionもbun.DBのwrapperみたいなところがあるので仕方ないかなと感じておりそんなに気にしていません。

transactionのオプションについては今は何も考えてないので、細かい制御をしたくなるとちょっと面倒かもしれません。
Exec()関数だけのところを、いくつか関数を用意してあげれば隠蔽しつつある程度柔軟にはできそうですかね?
細かいオプションをひたすらに色んな場所でちょっとずつ違う形で設定することは稀だと思うので...

満点ではなく課題感も残るものの、比較的簡易なやり方で期待する動きが網羅できているのかなぁという気がしています。
仕事で使うかと言われると、もうちょっと試行錯誤して考えたいですが。

この実装を行うまでに考えたりしたこと

ここから下は消化試合です。
どんなことを考えていたのか紆余曲折をだら〜っと残してみます。

そもそもbunのtransactionはどんなインターフェース

トランザクション処理を隠蔽したいけど、そもそも普通はどう書くのか。
調べてみたらこんな感じ

// Insert several users in a transaction.
err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
    if err := InsertUser(ctx, tx, user1); err != nil { // _, err := tx.NewInsert().Model(user1).Exec(ctx)
        return err
    }
    if err := InsertUser(ctx, tx, user2); err != nil { // _, err := tx.NewInsert().Model(user2).Exec(ctx)
        return err
    }
    return nil
})

// bud.IDB型に注目
func InsertUser(ctx context.Context, db bun.IDB, user *User) error {
	_, err := db.NewInsert().Model(user).Exec(ctx)
	return err
}

トランザクションの境界を作るときにbun.Txという型のインスタンスが得られるので、それを利用して処理したものがトランザクションの対象になるつくりみたいです。

そしてこれはbun.IDB型とも互換性があり、通常利用するbun.DB型もそこと互換性があります。
tx, dbはこのbun.IDBを共通の型として取り回せは良さそう。

txからNewInsert()などのDBアクセス処理をチェインしないといけないのが面倒くさそう。
Repositoryでbun.DBを使うとトランザクションを使うときと使わないときで、txとdbを差し替えないといけない。
DIは起動時に一発で済ませたいし、repositoryのインスタンスの生成をusecaseから直接行ったりはしたくない。
となると、repositoryでどうやってその辺を制御するのがいいのだろうか。

transaction用のrepositoryみたいなのを検討

repositoryのnewを起動時の一連の流れに寄せたいが、それは必ずしもそうじゃないといけないわけではないので無視してみる。
その場合どういう感じになりそうか書いてみる。

// core
type Transaction interface {
	Exec(context.Context) error
}

// infra
type Transaction struct {
	db *bun.DB
}

func (tx *core.Transaction) Exec(ctx, fn function(ctx, tx) error) error {
	db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
		fn(tx)
	}
}

type UseCase struct{ ... }
func NewUseCase(...) UseCase{...}

func (u UseCase) Do(ctx) error {
	u.transaction.Exec(ctx, func(ctx, tx) error{
		repo := repositoryimpl.NewHogeRepository(tx)
		res, err := repo.Method(ctx)
		...
	})
}

なんだか悪くないような気がするがinfrastructureにあるrepositoryimplが流出してしまっている。
これだと駄目なので、なんとかNewを隠蔽できないか。

// core
type RepositoryFactory interface {
	NewHogeRepository()
}

// infra
type RepositoryFactory struct{
	db *bun.DB
}

func (r core.RepositoryFactory) NewHogeRepository() repository.HogeRepository {  //coreのrepo
	return repositoryimpl.NewHogeRepository(r.db)
}

func NewUseCase(
	factory core.RepositoryFactory
)UseCase{...}

func (u UseCase) Do(ctx) error {
	u.transaction.Exec(ctx, func(ctx, tx) error{
		repo := u.factory.NewHogeRepository(tx)
		res, err := repo.Method(ctx)
		...
	})
}

一応型が流出しないように隠蔽できている気がするけど、DI用のInjectorとFactoryがやってることが同じで重複している。
愚直に定義をどんどん増やしていくのはGoっぽさも感じるけど、役割が重複している点はやっぱり気になる...

あんまり悪いと断じるほど悪いと感じてはいないけど、もっと楽にやれたら嬉しいな。

ついでにTransactionをTransactionRepositoryみたい名前にしようかと思ったけど、トランザクションはrepository関係ないからpersistenceというpackageに属させてみた。
Exec関数はRunInTxとかでよかったかもしれない。TransactionStarterというstruct名も考えたけど、かっこ悪いのでやめた。

どうにかして元々のrepositoryの実装を変えずにtx, dbを差し替えられるのか

Repositoryにbun.DBをDIしてるうちはコンストラクタでどうにかするしかないので、まずそこをかえる必要がある。
メソッドの引数に追加したくないし、メソッドの引数に追加するとinterface定義側にもbunの型が流出するので、それは無理。
bun.DBに一枚噛ませて処理を差し込む余地を作るしかない

var handler *bun.DB
var once sync.Once

func NewHandler(cfg config.Postgres) *Handler {
	once.Do(func() {
		sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(getDSN(cfg))))

		handler = bun.NewDB(sqldb, pgdialect.New())
	})

	return handler
}

元々こうしていたhandler(bun.DB)の処理を変更して以下にしてしまおう。

var handler *bun.DB
var once sync.Once

type Handler struct {
	db *bun.DB
}

func NewHandler(cfg config.Postgres) *Handler {
	once.Do(func() {
		sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(getDSN(cfg))))

		handler = bun.NewDB(sqldb, pgdialect.New())
	})

	return &Handler{
		db: handler,
	}
}

メソッドを生やす余地が生まれたので、ここでなんとかしてしまいたい。
どうやれると楽だろうか。

func (h Handler) DB() bun.IDB {
	if (...) {
		return ...
	}
	return ...
}

こんな感じで切り替えられたら嬉しいのだけど。

context.Contextって値入るよな

そういえばRepositoryって絶対にctxを受け取るし、contextって値を入れることも一応できるのでそれも検討してみてもいいかもしれない。
contextが使えるなら、以下のように実装できるはず

func (h Handler) DB(ctx context.Context) bun.IDB {
	tx := ctx.Value("key")
	if (tx != nil) {
		return tx
	}
	return h.db
}

試しに実装してみよう。

type Transaction struct {
	db *Handler
}

func (t *Transaction) Exec(ctx context.Context, f func(ctx context.Context) error) error {
	return t.db.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
		context.WithValue(ctx, key, &tx)
		err := f(ctx)
		context.WithValue(ctx, key, nil)
		return err
	})
}

func (r HogeRepository) FindByID(ctx context.Context, id identifier.HogeID) (*model.Hoge, error) {
	var t table.Hoge

	if err := r.handler.DB(ctx).NewSelect().
		Model(&t).
		Where("id = ?", id.UUID()).
		Scan(ctx); err != nil {
		return nil, errors.Wrap(err)
	}

	return convert.ToP(toHogeModel(t)), nil
}

Repository側は影響が最小限だし良さそう。
今はDB()というメソッドにしているけど、いつの日かWriter()Reader()というのを生やしたくなるかもしれないけど、そのときも大丈夫そう。

ただWithValueのところが正直相当微妙。
処理のあとにnilにしておかないと、一連の処理の一部はtransactionを貼って、残りはtransaction貼りたくないみたいなケースで事故るのでこうするほうが良いのかなと思った。

ただこうするとcontextの使い方がリクエストスコープではなくなってしまっているので、contextの用法に適していないというニュアンスが加速している。
DBアクセス自体はcontextで管理されることに違和感がないので、ギリセーフということにしたい...

あとtransactionの中で実行したselect結果を取得するは大丈夫か気になるので一応試しておきたい

func (u UseCase) Do(...) error {
	var hoge Hoge
	u.transaction.Exec(ctx, func() {
		hoge, err = u.hogeRepo.FindByID(ctx, ...)
	})
	fmt.Printf("%o", hoge)
}

クロージャーなので、普通にこれで値をキャプチャできた。
素敵ではないけど問題はなさそう

Exec()の第2引数の関数にGenericsを使えばreturnの型を任意に指定して戻せるようにできるような気もするけど、今回はそこまで試していない。
Goのジェネリクスがそれが出来たかどうかはパッと覚えていない...

全体を通して

胸を張って人にオススメは出来ないけど、一旦contextを使ったパターンが一番他の実装を汚さないし融通が効くし楽に使えそうなので、これで試してみようと思いました。

これよくわからないけどプロセスIDみたいなのを識別子に追加したらgoroutineでも動くように出来ないかなぁ

あとがき

こんな感じで冒頭で記載した実装でとりあえず行くか...みたいになりました。
今回はbunを利用していますが、多分gormでも変わらないと思います。

他にもっとスマートなやり方をご存知の方いたら、教えてほしいです。
なんか僕もカッコよく「これが王道です」みたいなものパッと作れたらいいんですけどねぇ。

Discussion