GoによるRepositoryパターンとUnit of Workを組み合わせたトランザクション処理
ハコベル物流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
})
この例は、ハンドラ内でSampleRepo
のUpdate1
, Update2
メソッドが連続して呼び出され、1つのトランザクションとしてDBに反映されることを想定しています。
unitOfWork
のDo
メソッド内でトランザクションが開始されて、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の使い方については以下の記事が詳しいです。
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
を活用して、unitOfWork
のDo
メソッドに渡ってきたハンドラの挙動の検証を行います。
repositoryManagerMock
ではリポジトリを参照するのにgetterを定義しているため、返り値をモックすることが可能です。このgetterの返り値としてsampleRepoMock
を指定し、ハンドラにrepositoryManagerMock
を渡すことで、テスト時にハンドラ内でsampleRepoMock
を利用できます。
そして今回の場合はSampleRepo
のUpdate1
, Update2
メソッドが正常に動作することを確認したいので、それぞれ正しい引数がメソッドに渡されて、かつ返り値としてnil
が返ってくることを確認できています。
まとめ
Unit of Workパターンの仕組みを使うことで、Repositoryパターンにおけるトランザクション処理を実装することができました。
unitOfWork
のDo
メソッド内でトランザクションの開始とリポジトリの初期化、そしてコミットまたはロールバックを行うので、アプリケーション層や永続化層とは切り離した形で、メソッドやリポジトリを跨ぐようなDB更新処理をアトミックに反映できます。
「物流の次を発明する」をミッションに物流のシェアリングプラットフォームを運営する、ハコベル株式会社 開発チームのテックブログです! 【エンジニア積極採用中】t.hacobell.com/blog/career
Discussion