GoのORM「Gen」を使ってみた

2024/03/19に公開

はじめに

こんにちは、mizukoです!

みんさんはGoのORMは何を使っていますか?
私はしばらくGormを使っていたのですが、結局難しい表現をしようとするとガッツリSQLを書く必要があり、「あれ?ORMとは...?」となる事が多々あります。
そもそもGo自体はORMを使わず標準パッケージを使う派閥がいるらしいですし、私自身が元々NodeやLaravel、Rails出身で、ORMならできて当たり前と思ってしまっている節があるのも原因かと思いますが...😢

そこで今回はGenを試しに使ってみた所感を記載していきたいと思います。

Genとは

Genとは、Gormプロジェクトの一部として開発されており、データベーススキーマからGoのコードを自動生成することで、型安全で高性能なデータアクセスを実現してくれるORMです。
あくまでGormプロジェクトの一部であり、GormでできないことはGenでもできないと思って頂いて良いと思います。

導入

ドキュメントにもある通り、Gormで既に運用している前提から導入は始まります。(もしくは、既にデータベースが存在する状況から始めるのが良さそうです。)

1. コード生成スクリプトを準備

クイックスタートにもあるコード生成のスクリプトを追加します。

package main

import (
	"log"

	postgres ".../infrastructure/persistence/postgres/gen"
	"gorm.io/gen"
)

func main() {
	g := gen.NewGenerator(gen.Config{
		OutPath:           "infrastructure/persistence/postgres/gen/query",
		Mode:              gen.WithoutContext | gen.WithDefaultQuery | gen.WithQueryInterface,
		FieldWithIndexTag: true,
		FieldWithTypeTag:  true,
		FieldNullable:     true,
	})

	db, err := postgres.LoadConfigAndCreateDBConnection()
	if err != nil {
		log.Fatalf("Could not connect to database: %v", err)
	}

	g.UseDB(db)
	all := g.GenerateAllTable()

	g.ApplyBasic(all...)

	g.Execute()
}

LoadConfigAndCreateDBConnectionはDBの接続用の関数です。
以下の様な接続処理を任意で設定して下さい。

db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})

2. コードを生成

1で追加したスクリプトを実行します。(パスは読み替えて下さい)

go run ./infrastructure/persistence/postgres/gen/generate/main.go

すると、スクリプトのOutPathで指定したパスにてqueryと同じ階層にmodelも生成されたかと思います。

3. 生成されたモデルに対してリレーション情報を追加するためのスクリプト準備

2で生成されたモデルを見ると、リレーションの紐付けが表現されていないと思います。
生成されたモデルに対してリレーション情報を別途追加する必要があるので、スクリプトを準備します。

package main

import (
	"log"

	postgres ".../infrastructure/persistence/postgres/gen"
	".../infrastructure/persistence/postgres/gen/model" // 2で生成したmodel
	"gorm.io/gen"
	"gorm.io/gen/field"
)

func main() {
	g := gen.NewGenerator(gen.Config{
		OutPath:           "infrastructure/persistence/postgres/gen/query",
		Mode:              gen.WithoutContext | gen.WithDefaultQuery | gen.WithQueryInterface,
		FieldWithIndexTag: true,
		FieldWithTypeTag:  true,
		FieldNullable:     true,
	})

	db, err := postgres.LoadConfigAndCreateDBConnection()
	if err != nil {
		log.Fatalf("Could not connect to database: %v", err)
	}

	g.UseDB(db)

	g.Execute()

	allModels := []interface{}{
		g.GenerateModel(
			model.TableNameCategory,
			gen.FieldRelateModel(field.Many2Many, "Memos", model.Memo{}, nil),
		),
		g.GenerateModel(
			model.TableNameMemo,
			gen.FieldRelateModel(field.BelongsTo, "User", model.User{}, nil),
			gen.FieldRelateModel(field.Many2Many, "Categories", model.Category{}, nil),
		),
		g.GenerateModel(
			model.TableNameNote,
			gen.FieldRelateModel(field.BelongsTo, "User", model.User{}, nil),
			gen.FieldRelateModel(field.Many2Many, "Memos", model.Memo{}, nil),
		),
		g.GenerateModel(
			model.TableNamePortfolio,
			gen.FieldRelateModel(field.BelongsTo, "User", model.User{}, nil),
			gen.FieldRelateModel(field.Many2Many, "Categories", model.Category{}, nil),
		),
		g.GenerateModel(
			model.TableNameUser,
			gen.FieldRelateModel(field.Many2Many, "Categories", model.Category{}, nil),
		),
	}

	g.ApplyBasic(allModels...)

	g.Execute()
}

model.TableNameが重複すると、後に書いた方が優先されるため気をつけて下さい。

4. 関連付け追加

3で追加したスクリプトを実行します。(パスは読み替えて下さい)

go run ./infrastructure/persistence/postgres/gen/association/main.go

modelを確認すると、先ほど定義した関連付けが追加されていると思います。

// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.

package model

import (
	"time"
)

const TableNameCategory = "categories"

// Category mapped from table <categories>
type Category struct {
	ID        int64     `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
	Name      string    `gorm:"column:name;type:character varying(100);not null" json:"name"`
	CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null" json:"created_at"`
	UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null" json:"updated_at"`
	Memos     []Memo    `json:"memos"` // ←ココ
}

// TableName Category's table name
func (*Category) TableName() string {
	return TableNameCategory
}

これでGenを使う準備が整いました🎉

生成されたqueryを使う

生成されたqueryを使うために以下を行う必要があります。

import ".../infrastructure/persistence/postgres/gen/query" // 生成したqueryのパス

query.Use(db) // dbはgormのコネクション

私の場合、クリーンアーキテクチャのディレクトリ構成のため、以下の様な感じになりました。

type categoryRepository struct {
	query *query.Query
	gorm  *gorm.DB
}

func NewCategoryRepository(db *gorm.DB) domainRepository.CategoryRepository {
	return &categoryRepository{
		query: query.Use(db),
		gorm:  db,
	}
}

func (r *categoryRepository) FindByID(id uint) (*domainModel.Category, error) {
	categoryQuery := r.query.Category
	category, err := categoryQuery.Where(categoryQuery.ID.Eq(int64(id))).First()

	if err != nil {
		log.Printf("Could not query category: %v", err)

		return nil, err
	}

	domainModel := mapper.ToDomainCategoryModel(category)

	return domainModel, nil
}

実際に使う時に、queryから該当のモデルに紐づくクエリを使う感じです。

構文

詳しい構文の違いはドキュメントを見て頂ければと思いますが、以下の様にタイプセーフに書けるようになります。良いですね〜〜
感覚としては、Gormで書く内容に型がついただけという感じなので、Gormを触っていた人は簡単にキャッチアップできると思います。
注意点としては、Genでは返り値がmodelとerrorになるので、Genへ移行する場合は面倒くさいポイントかと思います。

Gorm

memos := []model.Memo{}
result := r.db.Preload("User", func(db *gorm.DB) *gorm.DB {
    return db.Select("id", "user_id", "email", "name", "picture")
}).Preload("Categories").Where("user_id = ?", userID).Find(&memos)

Gen

m := r.query.Memo
u := r.query.User

memos, err := m.Where(m.UserID.Eq(int64(userID))).
    Preload(m.User.Select(u.ID, u.UserCode, u.Email, u.Name, u.Picture)).
    Preload(m.Categories).Find()

良かったところ

タイプセーフになる

冒頭でも説明した通り、Genの一番の魅力かと思います。
これにより、コンパイル時にエラーを検出できるため、ランタイムエラーを減らすことができます。
えらい

ボイラープレートコードを減らせる

ボイラープレートコードとは、繰り返し使用される定型的なコードのことです。
例えば、paginateを伴う取得処理はgormだと以下ですが、

db.Preload("Categories", func(db *gorm.DB) *gorm.DB { 
    return db.Offset(0).Limit(10)
})

genであれば、queryを生成したタイミングで以下のようなコードを自動で生成してくれます。

func (c categoryDo) FindByPage(offset int, limit int) (result []*model.Category, count int64, err error) {
	result, err = c.Offset(offset).Limit(limit).Find()
	if err != nil {
		return
	}

	if size := len(result); 0 < limit && 0 < size && size < limit {
		count = int64(size + offset)
		return
	}

	count, err = c.Offset(-1).Limit(-1).Count()
	return
}

良くなかったところ(思ってたんと違ったところ)

Gorm以上のことができるようになるわけではない

こちらも冒頭で記載したのですが、Genを入れたからといって、Gorm以上のことができるようになるわけではない点です。
例えば、リレーション先のデータで絞って抽出したい場合、以下の様にJoinしなければいけないのですが、

memo := []domainModel.Memo{}
query := r.gorm.Preload("User", func(db *gorm.DB) *gorm.DB {
    return db.Select("id", "user_id", "email", "name", "picture")
}).Preload("Categories").
    Joins("LEFT JOIN memo_categories on memos.id = memo_categories.memo_id").
    Where("memos.user_id = ? AND memo_categories.category_id = ?", userID, categoryID)

~ 略 ~

genで上記の様なjoinを書かない方法で実装することはできないです。
以下の様にしたら、内部的に中間テーブルもjoinしてくれれば良かったんですけどね...

memos, err := m.
    Preload(m.User.Select(u.ID, u.UserCode, u.Email, u.Name, u.Picture)).
    Joins(m.Categories.Where(c.ID.Eq(int64(categoryID)))).
    Where(m.UserID.Eq(int64(userID))).
    Order(m.UpdatedAt.Desc()).
    Find()

上記だと、memoテーブルに対してcategoryテーブルをjoinしてしまいます(そりゃそうだ)

ドキュメントに記載のある様に、関連の値自体は絞って取得できそうなんですが、今回の様に「指定したカテゴリーが存在するmemoを取得したい」という場合は、どうするの?という感じです。
有識者の方いたらお願いします🙇‍♂️

中間テーブルのqueryがなぜか登録されない

先ほどの続きです。
じゃあ、中間テーブルをjoinするか〜と以下の様な感じで実装しようとすると...

mc := r.query.MemoCategory

memos, err := m.
    Preload(m.User.Select(u.ID, u.UserCode, u.Email, u.Name, u.Picture)).
    Joins(mc.Where(mc.CategoryID.Eq(int64(categoryID)))).
    Where(m.UserID.Eq(int64(userID))).
    Order(m.UpdatedAt.Desc()).
    Find()

なんと、query/gen.goに、中間テーブルが登録されておらず、query.MemoCategoryが使えない!
query自体は生成されいるのですが、中間テーブル自体の操作はさせないということなんでしょうかね...?
queryやmodelはNot Editで生成されるので、解除して直で修正するのもなぁという感じで、結局gormの書き方で実装しちゃってます。

モデルの型のマッピングを別途設定する必要がある

元々のgormで以下の様に定義していたのですが、

type Category struct {
	ID        uint      `gorm:"primarykey"`
	Name      string    `gorm:"type:varchar(100);not null"`
	CreatedAt time.Time `gorm:"not null"`
	UpdatedAt time.Time `gorm:"not null"`

	Memos []Memo `gorm:"many2many:memo_categories;" `
}

スクリプトで生成したmodelを見ると

type Category struct {
	ID        int64     `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true" json:"id"`
	Name      string    `gorm:"column:name;type:character varying(100);not null" json:"name"`
	CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null" json:"created_at"`
	UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null" json:"updated_at"`
	Memos     []Memo    `gorm:"many2many:memo_categories;"`
}

IDがint64になってますね。
スクリプトを工夫すればマッピングか可能っぽいですが、ここにも一手間かかるのがなぁという感じです。

まとめ

今回は、Genの簡単な使い方と所感をまとめてみました!
Genを導入しよう!というモチベーションというよりかは、Gormを既に使い倒しているプロジェクトに、拡張機能として導入してみるくらいが丁度良いかなと思いました。
Gorm使いの方は是非試してみて下さい!

この記事読んで役に立った!と思ったらぜひいいねをお願いします👍
近い将来新しいサービスをリリースする予定なので、ぜひフォローをしてお待ち頂けますと幸いです🫶

参考

https://gorm.io/gen/index.html
https://qiita.com/muff1225/items/f660270694f29597df22#selectの違い

Discussion