Help us understand the problem. What is going on with this article?

go による transaction-aware な repository 層の実装の一例

Clean Architecture などにおいては、DB などのアクセスを抽象化した repository 層を作成することと思います。この repository 層を自分が作るとほぼ全ての言語でいつも同じ方式になるのですが、あまり同じ形式を見ないので、その方法を共有します。今回の実装言語は go としますが、rust でも Java でも Scala でも毎回この方式になるので、実装は言語に依存したものではありません。なお、この形式は超大規模なものにはおそらく向いていないので、そこだけお断りしておきます。

以下では、適当な TODO アプリの様なデータを仮定します。RDB でいうと、users, tasks テーブルがあると思ってください。対応するモデルの定義は以下とします。

type UserID int
type User struct {
    ID   UserID
    Name string
}

type TaskID int
type Task struct {
    ID      TaskID
    UserID  UserID
    Content string
}

ID には独自の型を与え、ID の取り違いが検出できる様にしています。

Repository のインターフェース

ブログなどを読んでいる限り、users テーブルや tasks テーブルへのアクセスは、UserRepositroyTaskRepository などのインタフェースを作ってそれを実装するようにすることが多いように思います。その場合、DB へのコネクションをどう表現するかが問題になりがちです。UserRepository の中でコネクションを取る様にしてしまうと、トランザクションで複数テーブルを同時に更新する場合に、UserRepositoryusers 以外の テーブルを変更する必要が生じるなど、うまく表現できません。そこで、コネクションは、UserRepository の様なインタフェースの外から与えるような形にした方が良いでしょう。今回紹介する形式は、その辺りの問題を解消しています。

まず Repository として、新しい接続を取れる関数だけを持つ、という抽象化を行ないます。

type Repository interface {
    NewConnection() (Connection, error)
    MustConnection() Connection
}

必要なのは NewConnection だけなのですが、テストで使うために MustConnection を生やしていることが多いです。

Connection は、次の様なインタフェースになっており、実際に users テーブルや tasks テーブルにアクセスできるインタフェースを Connection 経由で取ることができます。

type Connection interface {
    Close() error
    RunTransaction(func(tx Transaction) error) error

    User() UserQuery
    Task() TaskQuery
}

type Transaction interface {
    User() UserCommand
    Task() TaskCommand
}

なお、users テーブルや tasks テーブルにアクセスできるインタフェースを適当な関数にしたり Repository 経由にしてしまうと、実際に UserQuery などを得るとき、次のパターンのいずれかの実装になると思われます。

r := NewRepository()
con := r.MustConnection()

// パターン A
user, err := UserRepository(con).Find(id)

// パターン B
user, err := r.User(con).Find(id)

// パターン C
user, err := r.User().Find(con, id)

ここで、repository 層をインタフェース経由で作るのは、その実際の実装が RDB にアクセスしてもいいし、テスト用の mock でもいいようにするため、ということを考慮に入れましょう。(A) は、con の実際の実装型によってUserRepository(con) が返す実装が異なるため、type switch が入りそうで NG (入らない様な実装にすることはできるが、結局それだったら con 経由と変わらない)。(B), (C) は、 rcon が一致しないようなコード (e.g. r はテスト用に作った fake な Repository なのに、con は本物の RDB 接続)を書くことができてしまうので、あまり好みではありません (もちろん実際にはそんなことは誰もしないのですが)。そもそも con から取れれば r はここでは不要なので、わざわざ r から取る理由がないような気がします。

Connection の説明に戻ります。

Close() はコネクションを閉じ、RunTransaction は transaction の実行を抽象化します。

User(), Task() メソッドは、users, tasks テーブルからデータを取ってくるためのインタフェースを返します。Transaction 型にも同様の User(), Task() メソッドが生えており、こちらは更新もできるような インタフェースを返します。具体的には次のようなものが考えられます。

type UserQuery interface {
    Find(id UserID) (*User, error)
    List(filter UserFilter) ([]*User, error)
}

type UserCommand interface {
    UserQuery

    UpdateName(id UserID, name string) error
}

type UserFilter struct {
    NameLike string
}

type TaskQuery interface {
    Find(id TaskID) (*Task, error)
    List(filter TaskFilter) ([]*Task, error)
}

type TaskCommand interface {
    TaskQuery

    UpdateContent(id TaskID, content string) error
}

type TaskFilter struct {
    UserID UserID
}

これは私的定型ですが、複数返すものは大体 List という名前にしており、その検索条件を Filter という形で書く様にしています。

実際の使い方としては、次に様になります。

// DB 用の repository を作成
r := db.NewRepository()
// テストでは r := fake.NewRepository() となるかもしれない

con, err := r.NewConnection()
if err != nil {
    return err
}
defer con.Close()

// User を取得
user, err := con.User().Find(UserID(1))
...

// Task を取得
tasks, err := con.Task().List(TaskFilter{
    UserID: UserID(1),
})
...

この方式の欠点としては、複数テーブルを JOIN してデータを返す様な場合が上手く表現しづらいです。主テーブルと思われるテーブル用のインタフェースに間借りしてメソッドを生やしていることが多いです。あるいは、JOIN したテーブル用の Query/Command interface を作ることもあります。

更新は必ず RunTransaction を経由して行ないます。Transaction インタフェースは、RunTransaction でしか得ることができず、更新系のメソッドは UserCommand などの Command 系にしか実装しない様にすることで、かならずトランザクション実行中にのみ更新が行なわれることを保証できます。

err = con.RunTransaction(func(tx Transaction) error {
    err := tx.Task().UpdateContent(TaskID(1), "new content")
    if err != nil {
        return err
    }

    return nil
})
if err != nil {
    // some error happened. internal error, commit error, etc.
    return err
}

実際の実装

さて、実際の実装を見てみましょう。ここでは、gorm を用いた実装を用意しました。すべての実装は、https://github.com/mayah/go-repository-sample にあげてあります。

Repository, Connection, Transaction は単に *gorm.DB を持つだけです。

func NewRepository(db *gorm.DB) repository.Repository {
    return &dbRepository{
        db: db,
    }
}

type dbRepository struct {
    db *gorm.DB
}

type dbConnection struct {
    db *gorm.DB
}

type dbTransaction struct {
    db *gorm.DB
}

NewConnectionClose も、gorm の場合、接続を隠蔽してくれているので、やることがありません。

func (r *dbRepository) NewConnection() (repository.Connection, error) {
    return &dbConnection{
        db: r.db,
    }, nil
}

func (con *dbConnection) Close() error {
    // We don't need to close *gorm.DB. No need to do anything.
    return nil
}

RunTransaction は、db.Begin() でトランザクションを作り、あとは関数を呼ぶだけです。関数内で error が返されれば Rollback し、そうでなければ Commit() すれば良いでしょう。

func (con *dbConnection) RunTransaction(f func(repository.Transaction) error) error {
    tx := con.db.Begin()

    err := f(&dbTransaction{db: tx})
    if err != nil {
        tx.Rollback()
        return err
    }

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

    return nil
}

UserQuery などを Connection から取るには次のようなメソッドを用意しておいて…

func (con *dbConnection) User() repository.UserQuery {
    return &dbUserRepository{db: con.db}
}
func (con *dbConnection) Task() repository.TaskQuery {
    return &dbTaskRepository{db: con.db}
}

func (tx *dbTransaction) User() repository.UserCommand {
    return &dbUserRepository{db: tx.db}
}
func (tx *dbTransaction) Task() repository.TaskCommand {
    return &dbTaskRepository{db: tx.db}
}

例えば次のように UserQuery 向けの Find を実装します。gorm は1つデータを検索して見つからなかった場合、RecordNotFound error を返すのですが、gorm のエラーを repository 層で見せたくないので、見つからない場合は単に nil, nil を返すようにしています。

type dbUserRepository struct {
    db *gorm.DB
}

func (r *dbUserRepository) Find(userID model.UserID) (*model.User, error) {
    db := r.db

    user := &model.User{
        ID: userID,
    }

    if err := db.First(user).Error; err != nil {
        if gorm.IsRecordNotFoundError(err) {
            return nil, nil
        }
        return nil, err
    }

    return user, nil
}

まとめ

repository 層の実装の一案を示しました。今のところ、この方式を使った場合に repository 層の実装に困ったことがありません。欠点としては、(1) join 時にどこにメソッド生やすかはちょっと困ることと、(2) repository があればどんなテーブルにもアクセスできるため、アクセスできるテーブルをちゃんと分けたければその分 repository を分ける必要がある、ことぐらいでしょうか。(2) は超大規模な開発では分けたくなるでしょうが、そこまで大きな開発であればマイクロサービス化したりと様々な対策が打たれていると信じているので、あまり問題にならないのではないかと思います。

mayah
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした