はじめに
アドベントカレンダー16日目、aptpodのしがないサーバーサイドエンジニアがお届けします。
弊社ではサーバーサイドのプログラミング言語としてGoを利用しています。
Goでアプリケーションを実装していく中で特に頭を悩ませた、Go x クリーンアーキテクチャ x トランザクションについて、学んだこと、どうやって対処したかを書きたいと思います。
題材について
解説するにあたり、クリーンアーキテクチャ1を意識したサンプルアプリケーションを作りました。
機能自体はしょうもない、CRD(Update無し!)です。
意識はしていますが、クリーンアーキテクチャの厳密さを言われるとそうでもないです。
しかし、その部分の議論は本題とずれますので特に触れません。
まず全体の絵を。
ざっくりクラス関連図
ざっくりシーケンス図
ユースケースのアクティベートの部分はトランザクションを意味しています。そこだけ目立たせてみました。
ソースコードはこちら。
ブランチ毎にトランザクションのかけ方を変えた実装があります。内容については後述します。
困ること
まず、Goのトランザクション制御の実装方法について簡単に説明します。
Goのトランザクション制御の方法として、よく見るコードはDBアクセスオブジェクトを利用してBegin/Commit/Rollbackする方法です。
具体的には次のようなコードです。(sqlxを使っていますが、どれも似たような感じかなと思います。)
// dbのオープン!
db, err := sqlx.Connect("postgres", "user=miya password=miya dbname=miya sslmode=disable")
if err != nil {
return err
}
tx, err := db.Begin()
if err != nil {
return err
}
// エラーが起きたらロールバック!成功したらコミット!
tx.Rollback()
// or
tx.Commit()
ここでのポイントはトランザクション制御はデータ層2に依存するということです。
一方で、クリーンアーキテクチャを採用したアプリケーションを実装する場合、トランザクション境界は、ユースケース層3におくことが多いです。
クリーンアーキテクチャの依存ルールを守ると、ユースケース層にはデータ層の関心事を持ち込むことができません。
ここで、
- Goのよくあるトランザクション制御の実装はデータ層に依存する。
- ユースケース層はトランザクション制御をかけたいが、データ層に依存できない。
という矛盾が生じます。この矛盾をどうやって解消していくかというのが本記事の主題になります。
バシッと答えはないので、未だ試行錯誤中でもありますが。。。
解説するにあたり、題材として上げたサンプルアプリケーションの accountInteractor#Store()
という処理にトランザクションを張りたいケースを考えていきます。
この処理内ではデータ層の関心事を持ち込んではいけません。
// InputPort I/F。 ControllerはこのI/F経由でユースケースを呼び出す。
type AccountInputPort interface {
Store(ctx context.Context, in *AccountStoreInput) (*entity.Account, error)
}
func NewAccountInteractor(ar entity.AccountRepository) AccountInputPort {
return &accountInteractor{
accountRepository: ar,
}
}
// UsecaseInteractor
type accountInteractor struct {
accountRepository entity.AccountRepository
}
// ユースケースの実処理。ここにトランザクションをかけたい
func (u *accountInteractor) Store(ctx context.Context, in *AccountStoreInput) (*entity.Account, error) {
return u.accountRepository.Store(ctx, entity.New(in.FirstName, in.FirstName))
}
解決案0 トランザクションのヘルパー
本当の解決案に進む前にGoのトランザクションのヘルパーと言いますか、ある種トランザクションの実装パターンがあるので簡単に紹介しておきます。
Goではエラーの即時ハンドリングを推奨しています。
そのため、トランザクションを開始してから、エラーが起きたらロールバックをするというコードを書こう4とすると、エラーをハンドリングするたびに Rollback
をコールするコードになるため、冗長で抜けも発生しがちです。
// この関数はトランザクション制御をする!
func Process(db *sqlx.DB) error {
tx, err := db.Beginx()
if err != nil {
return err
}
if err != nil {
// 冗長
tx.Rollback()
return err
}
if _, err := tx.NamedExec(...); err != nil {
// 冗長
tx.Rollback()
return err
}
if _, err := tx.NamedExec(...); err != nil {
// 冗長
tx.Rollback()
return err
}
if _, err := tx.NamedExec(...); err != nil {
// 冗長
tx.Rollback()
return err
}
if _, err := tx.NamedExec(...); err != nil {
// 冗長....いや!漏れてる!ここ通るとトランザクション終わらないよ!
return err
}
tx.Commit()
return err
}
この問題を解消するために、トランザクションの処理を抽象化したヘルパー関数を用いると便利です。
// `f` という引数が、任意のトランザクション内のコールバック関数
func DoInTx(db *sqlx.DB, f func(tx *sqlx.Tx) (interface{}, error)) (interface{}, error) {
tx, err := db.Beginx()
if err != nil {
return nil, err
}
v, err := f(tx)
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return nil, err
}
return v, nil
}
このような実装があると、トランザクションを利用する側のコードはトランザクションのBegin/Commit/Rollbackを意識することなく実装できます。
エラーが返ると、ロールバックというのも直感的ですね。deferで全部拾う実装のほうがきれいかな。
tx.Rollback()
とか tx.Commit()
とかも error
を吐くので厳密にはエラーハンドリングしきれていませんが、これも主題とはズレるので無視してください。
次にこのヘルパー関数の利用側の実装を見ていきます。Begin/Commit/Rollbackが出てこず、冗長だった Rollback
も無くなります。
func Process(db *sqlx.DB) error {
_, err := DoInTx(db, func(tx *sqlx.Tx) (interface{}, error) {
if err != nil {
return nil, err
}
if _, err := tx.NamedExec(...); err != nil {
return nil, err
}
if _, err := tx.NamedExec(...); err != nil {
return nil, err
}
if _, err := tx.NamedExec(...); err != nil {
return nil, err
}
if _, err := tx.NamedExec(...); err != nil {
return nil, err
}
return nil, nil
})
return err
}
スッキリですね!
さて、ではいよいよ本題です!
解決案1 ユースケース層をラップする
accountInteractor
をラップした、トランザクションだけを管理する薄い AccountInputPort
の実装を作るというのが、解決案1です。実装を見ていきましょう。
type txAccountInteractor struct {
db *sqlx.DB
}
// データベースのオブジェクトをもらって InputPort実装を返却する。
func NewAccountInteractorTx(db *sqlx.DB) AccountInputPort {
return &txAccountInteractor{db: db}
}
func (u *txAccountInteractor) Store(ctx context.Context, in *AccountStoreInput) (*entity.Account, error) {
// トランザクションを開始して本当のユースケースの実装作って呼び出すだけのうすーーーい処理。
v, err := database.DoInTx(u.db, func(tx *sqlx.Tx) (interface{}, error) {
ar := database.NewAccount(tx)
return NewAccountInteractor(ar, dr).Store(ctx, in)
})
return v.(*entity.Account), err
}
トランザクションの制御処理と、ユースケース層のロジックを分離できています。
こうすると、例えば、純粋なユースケースロジックのテストをしたければ元実装の NewAccoiuntIntractor()
で生成したAccountInputPort
I/F経由で行えば問題ありません。この時の Repository
はよしなにmockを利用することができ、データ層に依存せずにロジックのテストが可能です。
アプリケーション実行時には NewAccountInteractorTx()
で生成した AccountInputPort
利用すればOKです。
メリット
- 分かりやすい
- 既存実装の拡張もしやすい
デメリット
- 実行時にインスタンスを都度作るのでその生成コストが無駄
- ユースケースの生成コスト
- リポジトリの生成コスト
- すべてのユースケースをラップするファクトリが必要なため記述が冗長になる。実装コストも増える。
- 自動生成とか作れば回避は可能だけど。
- トランザクションありのラップした実装はどこの層だろうか
- データ層に依存しているためユースケース層ではない。データ層に
InputPort
の実装を持っていくのもおかしい。 - アプリケーション立ち上げ時のmain関数の責務が妥当かな。
- データ層に依存しているためユースケース層ではない。データ層に
解決案2 コンテキストにトランザクションオブジェクトをセットする
この案は少し分かりづらいのでまずクラス関連とシーケンスから。
クラス関連
シーケンス図
図と実装と行き来しながらですががんばっていきましょ。
では、実装を追っていきます。
まず、トランザクション処理が必要なユースケースおよび、リポジトリのメソッドシグニチャに、 context.Context
を指定します。題材に示したすべてのユースケースメソッド、及びリポジトリにはcontext.Context
が第一引数にあるので、ここのコードは割愛します。
次に、トランザクション管理用のI/Fです。解決案0で示したヘルパ関数をI/Fとして切り出したイメージです。
大きく異なる点は、ヘルパ関数のメソッドシグニチャの第一引数は *sqlx.Tx
でしたが、 context.Context
とし、データ層との依存を切り離している部分です。
トランザクション管理用のI/Fは外界への依存度を低くし、どこからでも利用できるようなユーティリティくらいの位置づけにする狙いがあります。
context.Context
は標準パッケージですが、I/Fなので *sqlx.Tx
構造体と比較し依存度は低いと考えて差し支えないでしょう。
// これはデータ層ではない!シグニチャのどこを見てもデータ層ぽいものはない!
type Transaction interface {
DoInTx(context.Context, func(context.Context) (interface{}, error)) (interface{}, error)
}
次はこのトランザクションI/Fの実装を見ていきます。この実装はデータ層に依存します。
トランザクション用のオブジェクトを context.Context
にセットし、トランザクション制御をかけたいコールバック用の関数にトランザクションオブジェクトを放り込んだ ctx
を連携します。
var txKey = struct{}{}
type tx struct {
db *sqlx.DB
}
func NewTransaction(db *sqlx.DB) transaction.Transaction {
return &tx{db: db}
}
func (t *tx) DoInTx(ctx context.Context, f func(ctx context.Context) (interface{}, error)) (interface{}, error) {
tx, err := t.db.BeginTxx(ctx, &sql.TxOptions{})
if err != nil {
return nil, err
}
// ここでctxへトランザクションオブジェクトを放り込む。
ctx = context.WithValue(ctx, &txKey, tx)
// トランザクションの対象処理へコンテキストを引き継ぎ
v, err := f(ctx)
if err != nil {
tx.Rollback()
return nil, err
}
if err := tx.Commit(); err != nil {
// エラーならロールバック
tx.Rollback()
return nil, err
}
return v, nil
}
// context.Contextからトランザクションを取得する関数も忘れずに!
func GetTx(ctx context.Context) (*sqlx.Tx, bool) {
tx, ok := ctx.Value(&txKey).(*sqlx.Tx)
return tx, ok
}
ユースケース層の Store()
を、transaction.Transaction
I/Fを使ってトランザクション制御を行うように修正します。
// トランザクションI/Fを受け取ってセット
func NewAccountInteractor(ar entity.AccountRepository, tx transaction.Transaction) AccountInputPort {
return &accountInteractor{
accountRepository: ar,
trancaction: tx,
}
}
type accountInteractor struct {
accountRepository entity.AccountRepository
trancaction transaction.Transaction
}
func (u *accountInteractor) Store(ctx context.Context, in *AccountStoreInput) (*entity.Account, error) {
// トランザクションを開始
v, err := u.trancaction.DoInTx(ctx, func(ctx context.Context) (interface{}, error) {
// 必ず、連携されたctxを使うこと!
return u.accountRepository.Store(ctx, &entity.Account{
UUID: genUUID(),
FirstName: in.FirstName,
LastName: in.LastName,
})
})
return v.(*entity.Account), err
}
最後にデータ層です。
context.Context
からトランザクションオブジェクトを取り出してクエリを実行します。余談ですが、ダックタイピングいいですね。
func (u *accountRepository) Store(ctx context.Context, account *entity.Account) (*entity.Account, error) {
var dao interface {
NamedExec(query string, arg interface{}) (sql.Result, error)
}
// トランザクションオブジェクトをコンテキストから取得する
dao, ok := GetTx(ctx)
if !ok {
// 見つからなかったときはよしなに。ここではすでに持っているdbオブジェクトを利用している。
dao = u.db
}
if _, err := dao.NamedExec("INSERT INTO account(uuid, department_uuid, first_name, last_name) VALUES(:uuid, :department.uuid, :first_name, :last_name)", account); err != nil {
return nil, err
}
return account, nil
}
お疲れ様でした。よく見ていただけると分かりますが、ユースケース層からはデータ層の関心事から分離できています。ちなみに、トランザクション管理はI/F経由なので、Noop実装作っておくと、テストとかに便利です。
type Noop struct {
}
func (n *Noop) DoInTx(ctx context.Context, f func(context.Context) (interface{}, error)) (interface{}, error) {
// 何もしない。
return f(ctx)
}
メリット
- 1と比較し、インスタンスの生成コスト減
- トランザクションの関心事が分離できており冗長コードが減る
デメリット
-
context.Context.Value()
乱用感が否めない- データアクセスオブジェクトをコンテキストに入れるのはアンチパターン
- ただ、トランザクションはスコープがあるので許容範囲か。オブジェクトも新規生成だしね。と思っている。
- リポジトリ/ユースケースに
context.Context
が必須 - アプリケーションの見通しが悪くなる
- データ層で取得したデータアクセスオブジェクトはどこでセットされているんだっけ?的な。
解決案その3 トランザクションを集約単位とし、結果整合を許容する
クリーンアーキテクチャはドメイン駆動設計(DDD)と相性がよく、その2つはセットで語られることが多いように思います5。
DDDには
- 集約という単位がある。
-
Repository
は集約単位である。 - トランザクションは集約の単位で行う。
- 1ユースケース1集約の操作となるようにアプリケーションを設計する
という原則(しっくりくる言葉が思いつかず...)があります6。
集約の詳細は省きますが、トランザクションの境界は、Repository
の実装である、データ層としてしまい、そもそもユースケース層にトランザクション境界を置くのはやめようというのが解決案3です。
type accountRepository struct {
db *sqlx.DB
}
func NewAccount(db *sqlx.DB) entity.AccountRepository {
return &accountRepository{db: db}
}
func (u *accountRepository) Store(ctx context.Context, account *entity.Account) (*entity.Account, error) {
// リポジトリの実装内でトランザクションの開始と終了をする。
val, err := DoInTx(u.db, func(tx *sqlx.Tx) (interface{}, error) {
if _, err := tx.NamedExec("INSERT INTO account(uuid, department_uuid, first_name, last_name) VALUES(:uuid, :department.uuid, :first_name, :last_name)", account); err != nil {
return nil, err
}
return account, nil
})
if err != nil {
return nil, err
}
return val.(*entity.Account), nil
}
メリット
- 一番実装がわかりやすい
デメリット
- そもそもDDDとか集約って何?といったところから、設計の敷居が高い。
- 高いけど、規模によっては頑張らなくちゃね!
- 結果整合を担保するアプリケーション設計難度が高いケースもあるかも
- 集約をまたいだ一貫性を担保したいということは結構ある。
で、結局どれなの?
それぞれ、一長一短ですが、個人的には解決3が良いかなと思っています。
トランザクションオブジェクトというのも、RDB固有かなと思いますし、Restとかでサービス間連携をすると結果整合を取らざるを得なくなりますので。
ただ、一貫性がとにかく大事で、RDBしか使いませんとかであれば、ユースケース単位で確実にトランザクションの開始終了を制御する1,2の方が良いと思います。
2019年7月19日追記ここから
解決3で実装して行ったのですが、トランザクションの境界は集約の操作単位であるものの、リポジトリの一つのメソッドがトランザクションの操作単位ではなく、あくまで集約の操作の単位はユースケースであるということを勘違いして不都合が出てきたので、解決1or2が良さそうです。個人的には2がおすすめです。
ここまで
行く末や実装コストなどを考えながら実装も変えていければよいのではないでしょうかね。アプリケーションの規模などが理由で、クリーンアーキテクチャもDDDも必要なの?という話もありますし。
以上です。ありがとうございました!