GORMを使ったComposableなリポジトリの実現方法

MF KESSAIでバックエンドのエンジニアをやっている@garsueです。

MF KESSAIではサービス開発初期からRDBへアクセスする際のO/R MapperとしてGORMを採用しています。 今回はMF KESSAIのバックエンドでGORMをどのように活用しているか簡単に紹介しようと思います。

当初の課題

弊社のシステムは基本的にドメイン駆動設計に則っているので、データの永続化と取得を行うリポジトリが存在します。 その具体的な実装の中でGORMを使っています。

当初の実装では、各リポジトリのメソッド内でクエリの組み立てを行っていたため、少しでも条件が異なれば別のリポジトリメソッドとして定義するような作りになっていました。 その結果、どんどんリポジトリが膨らんでいき、再利用性が低く見通しの悪いコードになるという問題が出てきました。

この問題はよくあるらしく、2018年の春のGo Conferenceでも同様の問題について語られています。 https://speakerdeck.com/linyows/become-a-gorm-feeling-and-use-gorm

上記のスライドではモデルにDBコネクションが内包されて無いことが原因とされていますが、私達はより具体的な問題としてリポジトリを利用する側で柔軟にクエリの内容を指定できないことが問題だと考えました。

リポジトリとして抽象度を保ちつつも柔軟にリポジトリ利用側のニーズに応える、すなわち Composableなリポジトリにする にはどうすればよいかという課題が生まれました。

Scopesの利用

上記課題を解決するため、GORMのScopesという機能を使うことにしました。Scopesはクエリの条件指定などを高階関数として表現し、それらを値としてDBに受け渡しできる機能です。

例えば以下のようなユーザーを取得をするリポジトリがあったとします。

type UserRepository interface {
	FindByID(db *gorm.DB, id int) (*User, error)
	FindByName(db *gorm.DB, name string) (*User, error)
}

type UserRepositoryImpl struct{}

func (u *UserRepositoryImpl) FindByID(db *gorm.DB, id int) (*User, error) {
	var user User
	if err := db.Where("id = ?", id).Find(&user).Error; err != nil {
		return nil, err
	}
	return &user, nil
}

func (u *UserRepositoryImpl) FindByName(db *gorm.DB, name string) (*User, error) {
	var user User
	if err := db.Where("name = ?", name).Find(&user).Error; err != nil {
		return nil, err
	}
	return &user, nil
}

ここに住所でユーザーを取得するメソッドを追加しようとしたら愚直に FindByAddress のようなメソッドをリポジトリインターフェイスに追加して実装しなおすしかありません。

Scopesを使えば以下のように書き換えられます。

type UserRepository interface {
	Find(db *gorm.DB, scopes ...func(*gorm.DB) *gorm.DB) (*User, error)
}

type UserRepositoryImpl struct{}

func (u *UserRepositoryImpl) Find(db *gorm.DB, scopes ...func(*gorm.DB) *gorm.DB) (*User*, error) {
	var user User
	if err := db.Scopes(scopes...).Find(&user).Error; err != nil {
		return nil, err
	}
	return &user, nil
}

func ByID(id int) func(*gorm.DB) *gorm.DB {
	return func(db *gorm.DB) *gorm.DB {
		return db.Where("id = ?", id)
	}
}

func ByName(name string) func(*gorm.DB) *gorm.DB {
	return func(db *gorm.DB) *gorm.DB {
		return db.Where("name = ?", name)
	}
}

リポジトリのインターフェイスとしては Find メソッドのみを提供し、その引数としてScopesを受け取ってクエリを組み立てるようにしています。

一方で ByID, ByName はリポジトリのメソッドではないことに注目してください。 これらの関数は単なるScopesを返す関数であって、リポジトリそのものとは独立しています。

住所を条件に取得したくなった場合でも、リポジトリの定義や実装はそのままに ByAddress のようなScopesを返す関数を定義し、リポジトリ利用側でそれを指定するだけでいいのです。

さらに、Scopesは可変長引数なのでリポジトリ利用側の任意の組み合わせで指定できます。呼び出し側は以下のようなコードになります。

// 特定のID、名前、住所を持つユーザーを取得
user, err := userRepository.Find(
	db,
	ByID(100),
	ByName("foo"),
	ByAddress("Tokyo"),
)

これでかなりComposableになりましたね。

さらなる課題

さてリポジトリを柔軟に利用できるようにはなりましたが、まだ課題があります。

GORMの機能であるScopesをそのままリポジトリインターフェイスに流出させてしまったため、リポジトリが直接GORMに依存する構造になってしまいました。 さらに抽象度を高めるためにはScopes(と *gorm.DB )をラップした型を別途定義すべきです。

が、そこまではやっていません。 理由としてはテスト時にモックする場合でも特に不都合がなく、他のO/R Mapperに乗り換えるような予定もないためです。

そのあたりは比較的割り切ってやってしまっています。

おわりに

ちょっとしたGORMの使い方でしたが、いかがだったでしょうか? もっとうまい使い方がある、これではこういう局面に対応できないなどありましたらご指摘いただけると幸いです。

もっと詳しい話を聞きたい方、GORMを仕事で使いたい方、Goで開発したい方はぜひお気軽にMF KESSAIへ遊びにきてください! (ご連絡はこちらから)