2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GoAdvent Calendar 2024

Day 15

GORM のイマイチなところ

Last updated at Posted at 2024-12-15

この記事は Go Advent Calendar 2024 の 15 日目の記事です🎄

今まで自分が携わった Go のプロジェクトではレイヤードアーキテクチャでリポジトリパターンを使って実装することが多かったのですが、最近とあるプロジェクトで試しに GORM を使い始めました。
(GORM v1 の頃は一括デリート騒動などで世間を賑わせていましたが、v2 はだいぶ色々と改善されてるんじゃないでしょうか)

実際に使ってて、ここがイマイチだなあという悩みポイントがいくつかあったのでまとめておきます。
理解が間違っていたり、「こうしたらいいよ!」という点があればマサカリもらえると助かります!

lazy load 機能がない

  • https://github.com/go-gorm/gorm/issues/5504
  • Preload しておかないとデータが存在しないのと同義になってしまう
    • Preload しなかったものはゼロバリューで初期化される
    • DB から fetch したデータをただ単に struct にマッピングしているだけで、オブジェクト生成以降は単なる構造体としてしか機能しないので、それはそう
type Order struct{}

type User struct {
	ID    int
	Order Order
}

func main() {
	user := User{}
	db.First(&user, 1)
	do(user)
}

func do(user User) {
	fmt.Println(user.Order) // 引数の user は Order がロードされているのかいないのかわからない
}
  • Rails を使ってきた身からすると、フィールドアクセス時にクエリを遅延発行できるとありがたい
    • 構造体インスタンスを fetch して、他の関数に引き回す際に、どのフィールドがロードされているのかが暗黙的になりすぎる
      • ので、引き回す場合は雑に全ての関連を Preload しておく、みたいな雑な実装になりがち
  • とはいえこれ、DB アクセスした結果 nil なのか、アクセスしてないから nil なのかをインスタンス側で保持できないと無理なので、struct を単なるマッピングの受け皿としてしか使わない薄いライブラリでは限界がある
    • ActiveRecord はリッチな分、いらないものまでデータ持つからメモリ効率悪い
      • 自分でチューニングする余地が少ないけど、一方何も考えなくていいので楽という点はある

ネストした関連を一発で Preload できない

  • 全ての関連を Preload する Preload All 機能があるが、これがネストした関連をロードしてくれない
  • "User" has many "Order"s has many "Item"s という関連があるとき、User に対して Preload All (clause.Associations)しても、User が持つ子階層(つまり Order)までしか Preload できず、孫階層(Item)はそれとは別に指定しないと load されない
var user User

// これだと Order はロードされるが、Item はロードされない
db.Preload(clause.Associations).Find(&user)

// Item も欲しい場合は以下のようにする
db.Preload(clause.Associations).Preload("Orders.Items").Find(&user)
  • つまり、モデル側で関連の変更があった際は、クエリ発行している箇所で Preload する関連の指定のメンテナンスが必要ということになるが、いろんなところに書くのでメンテがしんどい
    • かつ孫以下の Preload の指定は文字列でやってるのでコンパイルエラーにもならない
      • type safety ではないのでこれは仕方ない
  • ということで、モデル側にヘルパー関数を作って、全ての関連を取得できるような Preload 設定を返せるようにしている
func (u *User) PreloadAll(db *gorm.DB) *gorm.DB {
	return db.Preload(clause.Associations).Preload("Orders.Items")
}

func main() {
    var user User
    db.Scopes(user.PreloadAll).Find(&user)
}
  • かなり雑な実装ではあるが、モデルの関連を変更した際に、そのモデルに対して定義されている PreloadAll を直すだけなので、修正はかなり楽になる
  • ただし、単件取得ならこれでいいが、複数件取得の場合にちょっと困る
    • 複数件取得の場合、受け皿はその構造体のスライスになる
    • 上記は User 構造体に対してメソッド定義しているだけなのでスライスに対しては呼べない
func main() {
    var users []User
    // スライスに対してメソッドは生えていないので、↓こうは書けない
    db.Scopes(users.PreloadAll).Find(&users)

    // こう書くことはできるっちゃできるが、PreloadAll が値型レシーバーメソッドとして定義されていないといけない
    db.Scopes(User{}.PreloadAll).Find(&users)
    
    // ちなみに clause.Associations はスライスに対しても機能するが、やはり孫関連は取れない
    db.Preload(clause.Associations).Find(&users)

    // 苦肉の策
    var user User
    db.Scopes(user.PreloadAll).Find(&users)
}
  • Go では一つの構造体に対して、値型レシーバーメソッドとポインタ型レシーバーメソッドを混在させるのはバッドプラクティスなので、どちらかに寄せる必要がある
    • 迷ったらポインタ型にしておくのが常
    • ここでは値型レシーバーメソッドの方法を取ると全て値型レシーバーに寄せる形になる
      • ポインタ型でもできなくはないが、呼び出すのにはポインタが取得できる形でオブジェクトが存在している必要があるので、事前に変数としてアロケートしておかないといけなくなり、微妙感がある
    • それか、type Users []User を定義して、それに PreloadAll を生やすというのも手ではあるが、全てのモデルにほぼ無意味であったとしてもスライス型を定義するというルールは結構辛い
      • そういうルールを追加していくと ORM 使ってる利点がどんどん薄くなっていく
    • メソッドではなく関数にしてしまうという手もなくはない
      • パッケージグローバルスコープになるので PreloadAllUser とかって関数名になるけど

primary key カラムは int 型にしておかないと面倒

  • 以下の記法で、id = 10 の User が select できる
var user User
db.First(&user, 10)
  • ただし、これ primary key カラムが int 型の時しか動かない
  • uuid など varchar にしていると、第二引数でそのままその文字列を指定してもうまく機能しないので、以下のように書く必要がある
var user User
db.First(&user, "id = ?", "1b74413f-f3b8-409f-ac47-e8c062e3472a")
  • これは公式ドキュメントにちゃんと書いてある
    • https://gorm.io/docs/query.html#Retrieving-objects-with-primary-key
    • 文字列の場合、インジェクション対策などの都合上、プリペアードステートメントを使うというのを明示しないといけないのかな
    • PK 指定の fetch の場合、そもそもキー名を省略していたりと特殊なイディオムになるので、type 見て GROM 側でよしなにできないんだろうか
  • ORM 使う時は大人しく PK を数値型にしておくのが色々都合がよさそう
    • DB のオートインクリメントに頼ることになりがちなので、モデルインスタンスの完全性が欲しいコーディングスタイルにはマッチしないけど

type safe ではない

  • リフレクションに頼っているので、いろんな箇所で文字列を使うことになる
    • 仕方ないっちゃ仕方ないけど、大掛かりなリファクタは辛そう
  • 一応、プラグインとして Gen というのがあり、これを使ってボイラープレート生成すれば Gorm で使えるタイプセーフな構造体と関数を自動生成してくれるらしい

Go にはクラスメソッドがない問題

  • 例えば、Ruby にはクラスメソッドという機能があるので、インスタンスを作らずにクラスに対してメソッド呼び出しを実行できる
  • 以下は User クラスに対して ActiveRecord のメソッドを呼び出して User インスタンスを取得している
user.rb
u = User.first
  • 一方、Go にはクラスメソッド機能がないので、fetch する際には受け皿用のインスタンスが事前に必要になる
var dst User // ここで空インスタンスを生成
db.First(&dst, id)
  • 事前に空のインスタンスを生成することになるので、不完全なインスタンス化を許容することになる
    • model インスタンスはコンストラクタで作りたいというルールがある場合にそのルールとコンフリクトする
  • じゃあこのインスタンス生成の処理を model 側に寄せてみる
    • User 構造体に FindByID みたいなメソッドを生やしたくなるが、メソッドはレシーバーがないと呼び出せない
    • そのため、関数にする必要があるが、関数のスコープはパッケージグローバルなので、model パッケージのように広い名前空間の実装をしている場合は名前衝突に気を使う必要がある
    • その結果、FindUserByID みたいな感じのがたくさんできる
model.go
package model

func FindUserByID(id int) (*model.User, error) {...}
func FindOrderByID(id int) (*model.Order, error) {...}
func FindItemByID(id int) (*model.Item, error) {...}
  • GORM のような ORM を使って詰め替えをなるべく少なくするためにドメインモデルと DB を密結合させる場合、モデルの完全性やらはあまり気にしない方針にしちゃった方がよさそう

まとめ

いくつか微妙な点を挙げましたが、概ね便利に使えています。
GORM を型安全にした Gen も気になるのでいつか使ってみたいですね。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?