🧑‍💻

GoによるRepositoryパターンとUnit of Workを組み合わせたトランザクション処理

2023/05/17に公開

ハコベル物流DXシステム開発部の坂東です。普段はサーバーサイドエンジニアとして、ハコベル配車計画の開発に携わっています。

今回の記事では、GoのRepositoryパターンとUnit of Workパターンを組み合わせたトランザクション処理の実装とテストの手法を、サンプルを使って紹介します。

背景

ハコベル配車計画ではドメイン駆動設計(DDD)の思想に基づき、Repositoryパターンを採用してサーバーサイドの開発を行なっています。

Repositoryパターンについて簡単に説明すると、レイヤードアーキテクチャにおいて、永続化層とアプリケーション層の間に中間層としてインターフェースを定義したリポジトリ層を用意したものを指します。このインターフェースを介してアプリケーション側から永続化に必要なメソッドを呼び出すことで、永続化処理の実装実体は隠蔽できるようになります。

つまり、リポジトリ層のインターフェースを介してアプリケーション側から永続化に必要なメソッドを呼び出すことで、永続化処理の実装詳細を隠蔽できるようになります。このため、永続化層の変更によるアプリケーション側への影響を最小限に抑えることができ、変更に強いアプリケーションを開発することができます。

今回はWebサーバー側にトランザクション処理を持たせるにあたってUnit of Workパターンを導入したため、本記事にてその実装方法についてご紹介します。

Unit of Workパターンとは

Unit of Workパターンは、エンタープライズ向けアプリケーションでよく用いられるデザインパターンの1つです。Patterns of Enterprise Application Architecture (PoEAA)の11章で紹介されています。

この本によれば、Unit of Workパターンは

Maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems.
ビジネストランザクションに影響を受けるオブジェクトのリストを維持し、変更の書き出しと並行処理の問題の解決を調整します。

と説明されています。

Repositoryパターンではエンティティに対する変更を永続化層でDBに反映しますが、ビジネスロジックの中で変更がある度にDB呼び出しを行うのは非効率です。またWebサーバーに複数のリクエストが同時に届くことを考えると、ビジネスロジックの実行中にDBの状態が変化してしまいデータ競合を招く恐れがあります。

そこで必要となるのがトランザクション処理の実装ですが、例えばアプリケーション層でこれを実装してしまうと、以下のようにアプリケーション層でDBのコネクションを保持してトランザクションの開始やリポジトリの初期化をする必要が出てきます。すなわち、アプリケーション層が永続化のための特定の処理に依存してしまいます。

type SampleService struct {
	db *sql.DB
}

func (s *SampleService) SampleUseCase1(ctx context.Context) {
	tx := s.db.BeginTx(ctx, nil)
	repo1 := NewRepo1(tx)
	repo2 := NewRepo2(tx)
	tx.Commit()
}

別の案としては、トランザクション処理を永続化層の中で実装することもできます。

type SampleRepository struct {
	db *sql.DB
}

func(r *SampleRepository) SampleUseCase1(ctx context.Context) {
	tx = r.db.BeginTx(ctx, nil)
	repo1 := NewRepo1(tx)
	repo2 := NewRepo2(tx)
	tx.Commit()
}

func(r *SampleRepository) SampleUseCase2(ctx context.Context) {
	tx = r.db.BeginTx(ctx, nil)
	repo1 := NewRepo1(tx)
	repo2 := NewRepo2(tx)
	tx.Commit()
}

この場合、トランザクション処理がリポジトリの特定のメソッド内で閉じてしまいます。すなわちアプリケーション層のUseCaseごとにメソッドを作る必要が出てきて、永続化層の実装をDRYに保つのが難しくなります。

この問題を解決するのがUnit of Workパターンです。

Unit of WorkパターンとDDDを組み合わせて使うことで、アプリケーション層から永続化のための処理を分離すること、永続化層のコードはDRYに書けること、そしてRDBのトランザクション機能でアトミックに変更を管理することの3つが同時に実現できます。

GoによるUnit of Workパターンのサンプル実装

次に、Go言語によるUnit of Workパターンのサンプル実装を紹介します。実装の詳細な部分については省略します。

前提

ここでは、以下のようなSampleRepositoryにUnit of Workパターンを適用することを考えます。

repositoryパッケージではSampleRepositoryのインターフェースを定義し、infraパッケージにてSamplerRepositoryの中身が実装されます。Update1, Update2メソッドでは何らかのDB更新処理を行うことを想定します。

package repository

type interface SampleRepository {
	Update1(ctx context.Context, arg1 interface{}) error
	Update2(ctx context.Context, arg2 interface{}) error
}
package infra

type sampleRepo struct {
	db *sql.DB
}

func NewSampleRepo(db *sql.DB) repository.SamplerRepository {
	return &sampleRepo{db: db}
}

func (s *sampleRepo) Update1(ctx context.Context, arg1 interface{}) error {
	// 何らかのDB更新処理
}

func (s *sampleRepo) Update2(ctx context.Context, arg2 interface{}) error {
	// 何らかのDB更新処理
}

Unit of Workパターン適用例

まず最初に、これから説明するUnit of Workパターンを使ったトランザクション処理の適用例をお見せします。

db, err := sql.Open("mysql", "user:password@tcp(localhost:3306)/database")
if err != nil {
	return nil, err
}

unitOfWork := uow.NewUnitOfWork(db)
unitOfWork.Do(ctx, func(ctx context.Context, uowRepoManager uow.RepositoryManager) error {
	err = uowRepoManager.SampleRepo().Update1(ctx, arg1)
	if err != nil {
		return err
	}

	err = uowRepoManager.SampleRepo().Update2(ctx, arg2)
	if err != nil {
		return err
	}

	return nil
})

この例は、ハンドラ内でSampleRepoUpdate1, Update2メソッドが連続して呼び出され、1つのトランザクションとしてDBに反映されることを想定しています。

unitOfWorkDoメソッド内でトランザクションが開始されて、unitOfWorkが管理する各リポジトリの初期化が行われます。DB更新のメソッドが正常に動作すればDBへの永続化処理がハンドラ終了時にコミットされ、エラーになればトランザクションがロールバックされることになります。

このようにして、Repositoryパターンとしてアプリケーション層から分離したDBへの永続化処理を、トランザクションとして1つにまとめることが可能です!

Unit of Workパターンの実装

Repositoryパターンを考慮したUnit of Workパターンの仕組みは、以下のように実装できます。

まずは、トランザクションを適用する対象のリポジトリを管理するrepositoryManagerです。

package uow

// RepositoryManagerの実装
type RepositoryManager interface {
	SampleRepo() repository.SamplerRepository
}

type repositoryManager struct {
	sampleRepo repository.SamplerRepository
}

func (u *repositoryManager) SampleRepo() repositories.SamplerRepository {
	return u.sampleRepo
}

func NewRepositoryManager(sampleRepo repositories.SamplerRepository) RepositoryManager {
	return &repositoryManager{sampleRepo: sampleRepo}
}

repositoryManagerではstructのフィールドにSamplerRepositoryインターフェースを持たせており、このフィールド自体はprivateにして、getter経由でsampleRepoリポジトリを参照するようにします。

そして、このrepositoryManagerで管理されるリポジトリに対してトランザクション処理を行うunitOfWorkの実装がこちらです。

package uow

// UnitOfWorkの実装
type UnitOfWork interface {
	Do(ctx context.Context, fn func(ctx context.Context, uowRepoManager RepositoryManager) error) error
}

type unitOfWork struct {
	db *sql.DB
}

func NewUnitOfWork(db *sql.DB) UnitOfWork {
	return &unitOfWork{db: db}
}

func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context, uowRepoManager RepositoryManager) error) error {
	tx, err := u.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}

	repoManager := NewRepositoryManager(
		infra.NewSampleRepo(tx),
	)

	if err = fn(ctx, repoManager); err != nil {
		if txErr := tx.Rollback(); txErr != nil {
			return err
		}
		return err
	}

	if err = tx.Commit(); err != nil {
		return err
	}
	return nil
}

この実装ではunitOfWorkのDoメソッドにて、dbのコネクションを元にトランザクションを開始し、それを使ってリポジトリの初期化・RepositoryManagerのコンストラクタの呼び出しをしています。

またここでは、RepositoryManagerに対して期待する処理をハンドラに定義して引数で渡すようにするのがポイントです。こうすることで、Doメソッド内で1つのトランザクションとして実行したい処理の挙動をモックしてテストできるようになります。具体的な呼び出し方、モックによるテストについては後述します。

ハンドラで定義された各リポジトリの操作が正常に実行されれば、tx.Commit()でトランザクションのコミットを行います。もしエラーが発生した場合は、tx.Rollback()でロールバックを行います。

mockを使ったテスト

ここではgomockを使って、unitOfWorkのテスト例を書きます。

gomockの使い方については以下の記事が詳しいです。

https://zenn.dev/sanpo_shiho/articles/01da627ead98f5

RepositoryManager, UnitOfWork, SampleRepositoryのそれぞれに対してインターフェースを定義しているおかげで、gomockを用いてモックの自動生成を行うことができます。

gomockの生成物を使った、具体的なテストの実装例がこちらです。

sampleRepoMock := repositories.NewMockSampleRepo(ctrl)
uowMock := uow.NewMockUnitOfWork(ctrl)
repositoryManagerMock := uow.NewMockRepositoryManager(ctrl)
uowMock.EXPECT().Do(gomock.Any(), gomock.Any()).DoAndReturn(
	func(ctx context.Context, fn func(ctx context.Context, uowRepoManager uow.RepositoryManager) error) error {
	repositoryManagerMock.EXPECT().PlanRepo().Return(sampleRepoMock).AnyTimes()
	repositoryManagerMock.SampleRepo().(*repositories.MockPlanRepo).EXPECT().Update(gomock.Any(), gomock.Eq(arg1).
		Return(nil).
		Times(1)
	repositoryManagerMock.SampleRepo().(*repositories.MockPlanRepo).EXPECT().Update(gomock.Any(), gomock.Eq(arg2).
		Return(nil).
		Times(1)
	return fn(ctx, repositoryManagerMock)
	})

gomockのDoAndReturnを活用して、unitOfWorkDoメソッドに渡ってきたハンドラの挙動の検証を行います。

repositoryManagerMockではリポジトリを参照するのにgetterを定義しているため、返り値をモックすることが可能です。このgetterの返り値としてsampleRepoMockを指定し、ハンドラにrepositoryManagerMockを渡すことで、テスト時にハンドラ内でsampleRepoMockを利用できます。

そして今回の場合はSampleRepoUpdate1, Update2メソッドが正常に動作することを確認したいので、それぞれ正しい引数がメソッドに渡されて、かつ返り値としてnilが返ってくることを確認できています。

まとめ

Unit of Workパターンの仕組みを使うことで、Repositoryパターンにおけるトランザクション処理を実装することができました。

unitOfWorkDoメソッド内でトランザクションの開始とリポジトリの初期化、そしてコミットまたはロールバックを行うので、アプリケーション層や永続化層とは切り離した形で、メソッドやリポジトリを跨ぐようなDB更新処理をアトミックに反映できます。

Hacobell Developers Blog

Discussion