公開日:
ビジネスロジック層の中でトランザクションを使いたい、でもビジネスロジック層にデータベース,トランザクション等の特定の永続化層のコードを書きたくない、そんなことはないでしょうか。
トランザクションを抽象化してビジネスロジック層の中で扱うコードを、Golang, ORマッパーのGORMを使って紹介します。
サンプルコードは以下のリポジトリに置いてます。
https://github.com/sano11o1/go-transaction
本投稿は Go (Golang): Clean Architecture & Repositories vs Transactions の実装を参考にしています。
ビジネスロジックを置く層をUsecase層と呼びビジネスロジックはここにまとめることとします。
Usecase層はプロジェクトによってはService層とも呼ばれていたりします。
Service層はApplicationService、DomainServiceに分かれている実装もありますが、説明をシンプルにするため、今回はUsecase層とします。
├── entity
│ └── user.go
├── go.mod
├── go.sum
├── main.go
├── repository
│ └── user_repository.go
└── usecase
└── register_user_usecase.go
Repository層はInterfaceとImplementに別れています。Implementで実際にデータベースにアクセスする処理を書きます。
package repository
# repository/user_repository.go
import (
"github.com/sano11o1/go-transaction/entity"
"gorm.io/gorm"
)
type IUserRepository interface {
AddUser(entity.User) error
}
type UserRepositoryImpl struct {
db *gorm.DB
}
func NewUserRepositoryImpl(db *gorm.DB) IUserRepository {
return &UserRepositoryImpl{
db: db,
}
}
func (r *UserRepositoryImpl) AddUser(user entity.User) error {
return r.db.Create(&user).Error
}
Usecase層の中でrepsitory層のInterfaceに定義されたメソッドを呼び出すことで、Usecaseが特定の永続化層を意識する必要しなくても良い構成になっています。
package usecase
# usecase/register_user_usecase.go
import (
"time"
"github.com/google/uuid"
"github.com/sano11o1/go-transaction/entity"
"github.com/sano11o1/go-transaction/repository"
)
type RegisterUserUsecase struct {
baseRepository repository.IBaseRepository
userRepository repository.IUserRepository
}
func NewRegisterUserUsecase(userRepository repository.IUserRepository) *RegisterUserUsecase {
return &RegisterUserUsecase{
userRepository: userRepository,
}
}
func (u *RegisterUserUsecase) Execute(user entity.User) error {
if err := u.userRepository.AddUser(entity.User{
Name: "sano11o1",
}); err != nil {
return err
}
return nil
)
データの保存先がデータベースからインメモリに変わっても、Usecase層に変更を加えなくても良いのです。
最後にmain.goです。データベースの構造体、RepositryのImplementを外から注入します。
package main
import (
"fmt"
"github.com/sano11o1/go-transaction/entity"
"github.com/sano11o1/go-transaction/repository"
"github.com/sano11o1/go-transaction/usecase"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func main() {
db, err := initDB()
if err != nil {
panic(err)
}
userRepository := repository.NewUserRepositoryImpl(db)
registerUserUsecase := usecase.NewRegisterUserUsecase(userRepository)
newUser := entity.User{
Name: "sano11o1",
}
if err := registerUserUsecase.Execute(newUser); err != nil {
fmt.Println("failed to register user", err.Error())
panic(err)
}
}
func initDB() (*gorm.DB, error) {
host := "127.0.0.1"
port := "5431"
user := "postgres"
dbName := "postgres"
password := "passw0rd"
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Tokyo", host, user, password, dbName, port)
var err error
db, err := gorm.Open(postgres.Open(dsn))
if err != nil {
fmt.Println("failed to connect database", err.Error())
return nil, err
}
return db, nil
}
まあ、よくある構成でしょうか。
そもそもUsecase層にトランザクションを扱わず、Repository層に書けば良いのではないか?という意見もあると思います。
私自身どの層でトランザクションで扱うべきかさまざまな実装、本を読んで勉強中です。
自分の中でどこに置くべきかは結論は出ていないため、どこでトランザクションを扱うべきかについては書きません。
もしもビジネスロジックの中でトランザクションを扱うなら、どのように実装するかのサンプルとして参考にしていただけたらと思います。
ユーザーを作成するUsecaseで、データベースと外部サービスの両方にユーザーを保存するケースを例に考えてみます。
最終的に、自社データベースと外部サービスの両方にデータがあるか、もしくは両方ともにデータがないことのどちらかに定まることを期待します。
APIを叩いた後にInsertする、DBにInsertした後にAPIを叩く、2通りの実装パターンが考えられます。
厳密に両方にデータが作られることを保証したい場合、前者は不適格でしょう。
func (u *RegisterUserUsecase) Execute(user entity.User) error {
// 外部サービスにUserを作成
req, err := http.NewRequest("POST", "https://example.com/user", nil)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := new(http.Client)
_, err = client.Do(req)
if err != nil {
return err
}
// DBにUserを作成
if err := u.userRepository.AddUser(entity.User{
Name: "sano11o1",
}); err != nil {
// Insertに失敗した場合外部サービスにのみデータが存在する
return err
}
return nil
}
レコード作成が何らかの理由で失敗した場合、外部サービスにのみデータが存在することになります。
外部サービスにユーザー作成を取り消すエンドポイントが実装されているかもしれません。
取り消しのリクエストを送信する実装も考えられますが、この通信が成功する保証はありません。
サービスにどれほどのデータの厳密さを求めるかによりますが、データの整合性を求めるなら順番を入れ替えるべきです。
func (u *RegisterUserUsecase) Execute(user entity.User) error {
// トランザクションの中で行いたい処理
{
// DBにUserを作成
if err := u.userRepository.AddUser(entity.User{
Name: "sano11o1",
}); err != nil {
return err
}
// 外部サービスにUserを作成
req, err := http.NewRequest("POST", "https://example.com/user", nil)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := new(http.Client)
_, err = client.Do(req)
if err != nil {
// 外部サービスとの通信が失敗した場合ロールバックする
return err
}
return nil
}
もしレコードのInsertに失敗した場合、APIへのPOSTは実行されません。
APIへのPOSTが失敗した場合、ロールバックすることでレコード作成を取り消します。
本題です。
Usecase層の中でトランザクション、データベースという永続化層の具象型を意識したコードは書きたくないですよね。
トランザクションを扱うコードをRepository層のImplementに実装するコードを紹介します。
package usecase
# usecase/register_user_usecase.go
import (
"net/http"
"github.com/sano11o1/go-transaction/entity"
"github.com/sano11o1/go-transaction/repository"
)
type RegisterUserUsecase struct {
baseRepository repository.IBaseRepository
}
func NewRegisterUserUsecase(baseRepository repository.IBaseRepository) *RegisterUserUsecase {
return &RegisterUserUsecase{
baseRepository: baseRepository,
}
}
func (u *RegisterUserUsecase) Execute(user entity.User) error {
atomicBlock := func(r repository.IBaseRepository) error {
userRepo := r.GetUserRepository()
if err := userRepo.AddUser(user); err != nil {
return err
}
// 外部サービスにUserを作成
req, err := http.NewRequest("POST", "https://example.com/user", nil)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
client := new(http.Client)
_, err = client.Do(req)
if err != nil {
// 外部サービスとの通信が失敗した場合ロールバックする
return err
}
return nil
}
err := u.baseRepository.Atmoic(atomicBlock)
return err
}
package repository
# repository/base_repository.go
import (
"gorm.io/gorm"
)
type IBaseRepository interface {
Atmoic(fn func(IBaseRepository) error) error
GetUserRepository() IUserRepository
}
type BaseRepositoryImpl struct {
db *gorm.DB
}
func NewBaseRepositoryImpl(db *gorm.DB) IBaseRepository {
return &BaseRepositoryImpl{
db: db,
}
}
func (r *BaseRepositoryImpl) Atmoic(fn func(IBaseRepository) error) error {
// r.dbするにはmain.goでdbを初期化しBaseRepositoryImplを生成する際に渡す必要がある
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(NewBaseRepositoryImpl(tx))
})
}
func (r *BaseRepositoryImpl) GetUserRepository() IUserRepository {
// NewUserRepositoryImplの引数にはBaseRepositoryImplのDBを渡す
return NewUserRepositoryImpl(r.db)
}
新たにbase_repository.goを定義し、トランザクションで実行する関数を受け取るAtomic
メソッドを定義します。
func (r *BaseRepositoryImpl) Atmoic(fn func(IBaseRepository) error) error {
}
Atomic
メソッドの引数には、トランザクションで実行する処理をまとめた関数を渡します。
変数atomicBlock
に代入した関数は引数にIBaseRepositoy
を受け取り、error
を返す関数です。
atomicBlock := func(r repository.IBaseRepository) error {
userRepo := r.GetUserRepository()
if err := userRepo.AddUser(user); err != nil {
return err
}
// 外部サービスにUserを作成
}
err := u.baseRepository.Atmoic(atomicBlock)
あくまでatomicBlock
は関数そのものの代入であり実行結果を代入しているわけではありません。
そのため引数r
の実態は関数を定義した段階では確定しないことに注意してください。Atomic
メソッドの実装をみていきます。
func (r *BaseRepositoryImpl) Atmoic(fn func(IBaseRepository) error) error {
// r.dbするにはmain.goでdbを初期化しBaseRepositoryImplを生成する際に渡す必要がある
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(NewBaseRepositoryImpl(tx))
})
}
引数には、fn func(r repositoy.IBaseRepository) error
を受け取ります。
受け取った関数fn
を呼び出しています。呼び出している箇所に注目してください。
GORMではトランザクションで実行したい処理をfunc(tx *gorm/DB) error
関数のブロックに記述、db.Transaction
メソッドの引数に渡します。func(tx *gorm/DB) error
関数がerror
を返した場合、ロールバックします。
参考: トランザクション
つまり、変数AtomicBlock
に代入した関数がerror
を返すとロールバックされるということです。
引数にはIBaseRepository
の実態であるBaseRepositryImpl
を生成し渡しています。BaseRepositryImpl
の生成時にtx
を渡しています。tx
はBaseRepositryImpl
構造体のフィールドdb
にセットされます。tx
はTransaction
メソッドを実行した時に確定する値で、同じtx
に対しメソッドを実行することで、共通のトランザクションを共有することができます。Atomic
メソッドの引数に戻ってIBaseRepository
の実態であるBaseRepositryImpl
がどのように使われるか確認します。
atomicBlock := func(r repository.IBaseRepository) error {
userRepo := r.GetUserRepository()
if err := userRepo.AddUser(user); err != nil {
return err
}
r
の実態はBaseRepositryImpl
です。r
に対してGetUserRepository
を呼び出します。
func (r *BaseRepositoryImpl) GetUserRepository() IUserRepository {
// NewUserRepositoryImplの引数にはBaseRepositoryImplのDBを渡す
return NewUserRepositoryImpl(r.db)
}
すると、構造体BaseRepositryImpl
のdb
フィールドの値を元に、UserRepositoryImpl
が作成されます。
これによりdb
フィールドにtx
を持ちます。AddUser
メソッド内部ではr.db
がtx
となるため、同一のトランザクションでInsert
が実行されることになります。
func (r *UserRepositoryImpl) AddUser(user entity.User) error {
return r.db.Create(&user).Error
}
最後にmain.go
を紹介します。
package main
import (
"fmt"
"github.com/sano11o1/go-transaction/entity"
"github.com/sano11o1/go-transaction/repository"
"github.com/sano11o1/go-transaction/usecase"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func main() {
db, err := initDB()
if err != nil {
panic(err)
}
baseRepostiry := repository.NewBaseRepositoryImpl(db)
registerUserUsecase := usecase.NewRegisterUserUsecase(baseRepostiry)
newUser := entity.User{
Name: "sano11o1",
}
if err := registerUserUsecase.Execute(newUser); err != nil {
fmt.Println("failed to register user", err.Error())
panic(err)
}
}
// initDB は割愛
当初はNewRegisterUserUsecase
の引数にUserRepositoryImpl
を渡していましたが、UserRepositoryImpl
はBaseRepository.GetUserRepository
を経由して生成することになったため、引数からは消えています。
新たにBaseRepositoryImlp
を渡しています。BaseRepositoryImpl
には*gorm.DB
を渡必要があります。
ここで渡さないと、Atomicメソッド内部でr.db.Transaction
を呼び出すことができません。
func (r *BaseRepositoryImpl) Atmoic(fn func(IBaseRepository) error) error {
// r.dbするにはmain.goでdbを初期化しBaseRepositoryImplを生成する際に渡す必要がある
return r.db.Transaction(func(tx *gorm.DB) error {
return fn(NewBaseRepositoryImpl(tx))
})
}
Exexcute
メソッドの実装を見ないと把握できない。(UserRepositoryImpl
はBaseRepository.GetUserRepository
を経由して生成することになったため)ビジネスロジック層の中でトランザクションをいい感じに扱う方法を検討しました。
repository層のInterface, Usecase層では「GORM」を一切使用していないコードになっています。
そのため他のORマッパーに書き換える場合はrepository層のImplementを書き換えるだけで良いはずです。
同様にImplementに手を入れるだけで、他の永続化層に乗り換えることができるはずです。
しかし永続化層を乗り換えるなんてこと、今後のプログラマー人生で経験することはあるのでしょうか。
その時は本実装のように、永続化層が抽象化されビジネスロジックと分離している実装になっていて欲しいものですね。