GolangとEchoで実装するトランザクション管理
環境
Webアプリケーションフレームワークとして「Echo」を、オブジェクトリレーショナルマッピング(ORM)には「Gorm」を採用しています。
皆さんの使用している開発環境や技術スタックに合わせて、適宜読み替えていただければ幸いです。
まず始めに
私は、今までにGolangでトランザクション処理を実装した経験がなく、どのように実装するのが最適か考えました。
弊社では、基本的にクリーンアーキテクチャを意識した開発を行なっています。
そのため、クリーンアーキテクチャの原則に違反しないか意識しつつ、より楽な実装を検討する必要があります。
コンテキストにトランザクションオブジェクトをセットする
下記の記事を参考にしました。
この記事では「解決案2」がおすすめという結論が出ており、それが「コンテキストにトランザクションオブジェクトをセットする」手法です。
ただ、この方法だと、下記の手間が発生します。
- usecase層にトランザクションをDI(依存性の注入、Dependency Injectionの略)する必要がある。
- 必要に応じ、トランザクションを開始させる必要がある。
そこで、少しでも楽をするための実装を考えてみました。
少しでも楽をするための実装
Requestごとにmiddlewareによってトランザクションを開始し、それをcontextにセットした後、
repository層では、contextにセットされたconnectionを取得し、操作を行うという流れになります。
package middlewares
import (
"github.com/labstack/echo"
"github.com/your-project/contextkeys"
"gorm.io/gorm"
)
type TransactionMiddleWare struct {
conn *gorm.DB
}
func NewTransactionMiddleWare(conn *gorm.DB) *TransactionMiddleWare {
return &TransactionMiddleWare{
conn: conn,
}
}
func (m TransactionMiddleWare) HandleTransaction(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
tx := m.conn.Begin()
c.Set(contextkeys.Transaction, tx)
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
err := next(c)
if err != nil {
tx.Rollback()
return err
}
if err := tx.Commit().Error; err != nil {
return err
}
return nil
}
}
ただし、この実装の場合には、middlewareから直接DBを操作しており、クリーンアーキテクチャの原則に違反しています。
そのため、DB操作は、Repositoryに移譲し、contextへのセットのみをmiddlewareで行うように修正します。
package middlewares
import (
"github.com/labstack/echo"
"github.com/your-project/contextkeys"
"github.com/your-project/repositories"
"gorm.io/gorm"
)
type TransactionMiddleWare struct {
tx repositories.ITransaction
}
func NewTransactionMiddleWare(tx repositories.ITransaction) *TransactionMiddleWare {
return &TransactionMiddleWare{
tx: tx,
}
}
func (m TransactionMiddleWare) HandleTransaction(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
execFunc := func(db *gorm.DB) error {
c.Set(contextkeys.Transaction, db)
return next(c)
}
return m.tx.ExecuteTransaction(execFunc)
}
}
package repositories
import (
"gorm.io/gorm"
)
type TransactionalExecFunc func(db *gorm.DB) error
type ITransaction interface {
ExecuteTransaction(fn TransactionalExecFunc) error
}
type Transaction struct {
conn *gorm.DB
}
func NewTransaction(db *gorm.DB) ITransaction {
return &Transaction{conn: db}
}
func (t *Transaction) ExecuteTransaction(fn TransactionalExecFunc) error {
tx := t.conn.Begin()
if tx.Error != nil {
return tx.Error
}
defer func() {
if r := recover(); r != nil {
tx.Rollback()
panic(r)
}
}()
if err := fn(tx); err != nil {
tx.Rollback()
return err
}
return tx.Commit().Error
}
Connectionを使い分ける
全ての処理でトランザクションを開始すると、読み取り専用のAPIでもトランザクションが開始され、テーブルロック等により処理パフォーマンスが落ちてしまうことが懸念されます。
基本的に、読み取り時にはトランザクションが必要ではないケースが多いので、そういう場合には、RepositoryにDIされたconnectionを使用します。
package repositories
import (
"errors"
"github.com/labstack/echo"
"github.com/your-project/contextkeys"
"github.com/your-project/domain"
"gorm.io/gorm"
)
type IUserRepository interface {
GetUser(echoCtx echo.Context, userID string) (*domain.User, error)
Create(echoCtx echo.Context, user *domain.User) error
}
type UserRepository struct {
conn *gorm.DB
}
func NewUserRepository(conn *gorm.DB) IUserRepository {
return &UserRepository{
conn: conn,
}
}
func (r *UserRepository) GetUser(echoCtx echo.Context, userID string) (*domain.User, error) {
q := r.conn.Where("id = ?", userID)
var user *domain.User
if err := q.First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return user, nil
}
func (r *UserRepository) Create(echoCtx echo.Context, user *domain.User) error {
conn := echoCtx.Get(contextkeys.Transaction).(*gorm.DB)
err := conn.Create(&user).Error
if err != nil {
return err
}
return nil
}
まとめ
今回は、少しでも楽をするためにという部分に重点をおいたトランザクション処理を実装しました。
しかしながら不必要にトランザクションを開始させることは、パフォーマンスを低下させ、デッドロックのリスクを増加させる危険性があります。
利用する際にはこれらのリスクを十分に理解し、適切な場面でのみ適用することが重要です。
大規模な業務用アプリケーションでは、より堅牢なトランザクション管理を検討することを推奨します。
Discussion