8
1

More than 1 year has passed since last update.

Gormをv1からv2へバージョンアップした話 [詳細編]

Last updated at Posted at 2022-12-22

クラウドワークス Advent Calendar 2022の23日目です!

以前、クラウドワークスエンジニアブログで「Gormのv1からv2へバージョンアップした話」という記事を投稿させていただきました。

そちらの記事ではバージョンアップ対応時の施策の進め方について書かせていただきました。

今回は実装面でどのように修正していったかについて実際のサンプルコードも交えてご紹介させていただきます!!

Defaultタグの挙動について

v2になりdefaultタグの挙動が変更されています。

v1ではデータベースのdefault値を利用するような動きになっていたのが、v2になりタグの値を正として動作するように変更されています。

どういう動きになるか具体的に紹介します。

まず下記のようなテーブルを用意しました。

age, memo, hogeにそれぞれdefault値を設定しています。

+------------+-----------------+------+-----+-----------+----------------+
| Field      | Type            | Null | Key | Default   | Extra          |
+------------+-----------------+------+-----+-----------+----------------+
| id         | bigint unsigned | NO   | PRI | NULL      | auto_increment |
| created_at | datetime(3)     | YES  |     | NULL      |                |
| updated_at | datetime(3)     | YES  |     | NULL      |                |
| deleted_at | datetime(3)     | YES  | MUL | NULL      |                |
| age        | bigint unsigned | YES  |     | 10        |                |
| name       | varchar(255)    | YES  |     | NULL      |                |
| memo       | varchar(255)    | YES  |     | memo test |                |
| hoge       | varchar(255)    | YES  |     | hoge      |                |
+------------+-----------------+------+-----+-----------+----------------+

検証コードは下記です。検証用に幾つかのパターンでdefaultタグを設定しました。

type Post struct {
	gorm.Model
	Name string `gorm:"DEFAULT:test"` // structにだけdefaultがついてる
	Age  uint   `gorm:"DEFAULT:10"`   // tableにもstructにもdefaultがついてる
	Memo string                       // tableにだけdefaultがついてる
	Hoge string `gorm:"DEFAULT:dog"`  // default値がテーブルの制約と別の値
}

func main() {
	p := Post{}
	if err := db.Debug().Create(&p).Error; err != nil {
		panic(err)
	}
	ps := []Post{}
	if err := db.Model(Post{}).Find(&ps).Error; err != nil {
		panic(err)
	}

	for _, p := range ps {
		fmt.Printf("Name: %s\n", p.Name)
		fmt.Printf("Age: %d\n", p.Age)
		fmt.Printf("Memo: %s\n", p.Memo)
		fmt.Printf("Hoge: %s\n", p.Hoge)
	}

}

結果下記のような結果が得られました

v1

v1の場合は構造体のフィールドがゼロ値かつdefaultタグを持っている場合はinsert文にカラムを含めずデータベースの機能を利用してdefault値を設定する動きになっていることが確認できました。

# コンソール
Name:              
Age: 10            
Memo:              
Hoge: hoge         
INSERT INTO `posts` (`created_at`,`updated_at`,`deleted_at`,`memo`) 
VALUES ('2022-09-26 09:40:40','2022-09-26 09:40:40',NULL,'')

v2

v2では構造体が持つフィールドをinsert文に全て含めた上でゼロ値かつdefaultタグを持っている場合はタグの値をそのまま利用するような動きになっています。

Name: test         
Age: 10            
Memo:              
Hoge: dog          
INSERT INTO `posts` (`created_at`,`updated_at`,`deleted_at`,`name`,`age`,`memo`,`hoge`) 
VALUES ('2022-09-26 09:40:14.287','2022-09-26 09:40:14.287',NULL,'test',10,'','dog')

上記の結果からv2への移行後もv1の時と同じ結果を得るために下記に該当する構造体について変更を加えることにしました。

  • 構造体だけがdefaultタグを持っているものについて構造体からdefaultタグを削除する
  • 構造体とTableのdefault値に差分があるものについて構造体のdefaultタグの値をテーブルに合わせる

実装していくにあたり工夫した点としては、Mysqlが持つinformation_schemaを利用して(クラウドログではRDBにMysqlを利用しています)、構造体の定義とテーブル定義に差分がないか検出する簡単なスクリプトを書いて対応を進めたことです。

テーブルの数がそれなりに多かったので効率よく作業を進められました。

Scopesの挙動が変更されていることについての注意点

v2への移行対応で一番ハマったのがこの挙動変更による対応でした。

GormにはScopesメソッドを利用してクエリの組み立て関数を共通化して使いまわせるようにできる機能が実装されています。

こちらはinterface自体はv1とv2で変更はないのですがコードを確認するとScopesにセットされた関数が実行されるタイミングが変更されており、クラウドログのコード内でそれによる影響がいくつか発生していました。

それぞれコードは以下となります。

v1

v1ではScopesにセットされた関数は即時実行されてクエリの組み立てインスタンスに反映されています。

func (s *DB) Scopes(funcs ...func(*DB) *DB) *DB {
	for _, f := range funcs {
		s = f(s)
	}
	return s
}

v2

v2では一旦scopesというフィールドに蓄積して、クエリの実行時に一気にクエリの組み立てインスタンスに反映するように変更されています。

func (db *DB) Scopes(funcs ...func(*DB) *DB) (tx *DB) {
	tx = db.getInstance()
	tx.Statement.scopes = append(tx.Statement.scopes, funcs...)
	return tx
}

Scopesにセットされた関数が反映されるタイミングv2

func (p *processor) Execute(db *DB) *DB {
	// call scopes
	for len(db.Statement.scopes) > 0 {
		scopes := db.Statement.scopes
		db.Statement.scopes = nil
		for _, scope := range scopes {
			db = scope(db)
		}
	}
  ...................................................
}

ではこの変更で「どのようなコードが影響を受けたか」と「どのように対処したか」をいくつかご紹介します。

OrderByを利用したScopes

クラウドログでは下記のようなコードが存在していました。

func OrderScopes() func(*gorm.DB) *gorm.DB {
	return func(db *gorm.DB) *gorm.DB {
		return db.Order("hoge DESC")
	}
}

func BuildQuery()  *gorm.DB {
	return db.
		Scopes(OrderSomething()).
		Order("fuga")
}

上記を実行した場合に出力されて欲しいOrder byのsql部分は以下です。そしてv1利用時は期待した通りの挙動をしていました。

ORDER BY hoge DESC, fuga

しかし、v2での変更でOrderScopes()でセットされた処理はクエリの実行時にまとめて実行されます。

そのためhoge DESCはOrder("fuga")よりも後にクエリに付加され下記のようなSQLが生成されます。

Order by句のように指定する値の順番によって結果が変わるようなクエリの組み立てをScopesで利用する場合は注意が必要です。

ORDER BY fuga, hoge DESC

こちらの対応としてはScopesの利用をやめて愚直にOrderメソッドに追加するようにしました。

func BuildQuery()  *gorm.DB {
	return db.
		Order("hoge DESC, fuga")
}

Selectを利用したScopes

次にselectを利用したscopesです。

単純にSelectメソッドを利用した共通関数をScopesで利用するだけなら問題ないのですが、Countメソッドと併用する場合は注意が必要です。

例えば下記のようにページネーションを実装する場合

  1. クエリの組み立てメソッドで条件を設定
  2. Countで全体の件数を取得
  3. ページネーションのクエリを利用して対象範囲を取得

のように実行したいケースがあるかと思います。

type User struct {
	ID        uint      `gorm:"column:id"`
	Name      string    `gorm:"column:name"`
	Email     string    `gorm:"column:email"`
	CreatedAt time.Time `gorm:"column:created_at"`
	UpdatedAt time.Time `gorm:"column:updated_at"`
}

func GetQuery() *gorm.DB {
	// クエリの組み立てロジック
	return db.Model(User{}).Where("name = ?", "hoge")
}

func Pagination(db *gorm.DB) *gorm.DB {
	// ページネーションのクエリを作成するロジック
}

func FindUsers() (users []User, cnt int64, err error) {
	q := GetQuery()
	if err = q.Count(&cnt).Error; err != nil {
		return
	}
	if err = q.Scopes(Pagination).Find(&users).Error; err != nil {
		return
	}
	return
}

上記のようなコードでCountメソッド実行時にSelectメソッドを含む共有関数をScopesにセットした場合意図しない挙動になります。

type User struct {
	ID        uint      `gorm:"column:id"`
	Name      string    `gorm:"column:name"`
	Email     string    `gorm:"column:email"`
	CreatedAt time.Time `gorm:"column:created_at"`
	UpdatedAt time.Time `gorm:"column:updated_at"`
}

func SelectScopes(db *gorm.DB) *gorm.DB {
	// Selectを利用したScopes
	return db.Select("id, name")
}

func GetQuery() *gorm.DB {
	// クエリの組み立てロジック
  return db.Model(User{}).Scopes(SelectScopes).Where("name = ?", "hoge")
}

func Pagination(db *gorm.DB) *gorm.DB {
	// ページネーションのクエリを作成するロジック
}

func FindUsers() (users []User, cnt int64, err error) {
	q := GetQuery()
	if err = q.Debug().Count(&cnt).Error; err != nil {
		return
	}
	if err = q.Scopes(Pagination).Find(&users).Error; err != nil {
		return
	}
	return
}

上記を実行した場合下記のようなSQLが出力されます。予想した結果と異なっているのではないでしょうか。

-- SELECT count(*) FROM `users` WHERE name = 'hoge'が出力されて欲しい
SELECT id, name FROM `users` WHERE name = 'hoge'
SELECT id, name FROM `users` WHERE name = 'hoge'

GormのCountメソッドの実装を確認すると以下のようになっていました。(一部抜粋)

func (db *DB) Count(count *int64) (tx *DB) {
	tx = db.getInstance()
.....................................................................
	if selectClause, ok := db.Statement.Clauses["SELECT"]; ok {
		defer func() {
			tx.Statement.Clauses["SELECT"] = selectClause
		}()
	} else {
		defer delete(tx.Statement.Clauses, "SELECT")
	}

	if len(tx.Statement.Selects) == 0 {
		tx.Statement.AddClause(clause.Select{Expression: clause.Expr{SQL: "count(*)"}})
	} else if !strings.HasPrefix(strings.TrimSpace(strings.ToLower(tx.Statement.Selects[0])), "count(") {
		expr := clause.Expr{SQL: "count(*)"}
.....................................................................

		tx.Statement.AddClause(clause.Select{Expression: expr})
	}

.....................................................................
	tx = tx.callbacks.Query().Execute(tx)

.....................................................................
	return
}

内容としてはSelect句をcountに書き換えてExecuteを実行して最後にdeferでSelect句を元に戻すような内容になっています。

もうお気づきかとは思いますが、Scopesに設定されたメソッドの実行はExecuteメソッド内で行われますので、Selectメソッドを利用する共通関数をScopesにセットした場合、Countメソッド内部で書き換えられたSelect句をExecuteメソッド内で書き換えることになります。

そのため、Scopes内でCount(*)を実行するSelect句を追加して無理やり上書く関数を追加して対応しました。

クラウドログではシンプルなCount(*)しか利用していなかったのでこちらの対応で十分でした。

Count(*)以外を利用している場合は適宜対応が必要そうです。

func CountScopes() func(*gorm.DB) *gorm.DB {
	return func(tx *gorm.DB) *gorm.DB {
		return tx.Select("COUNT(*)")
	}
}

unc FindUsers() (users []User, cnt int64, err error) {
	q := GetQuery()
	if err = q.Session(&gorm.Session{}).Scopes(CountScopes()).Count(&cnt).Error; err != nil {
		return
	}
	if err = q.Scopes(Pagination).Find(&users).Error; err != nil {
		return
	}
	return
}

メソッドチェインを利用したクエリの書き方

次にv2での大きな変更点であるメソッドチェインを利用したクエリの組み立て方についてです。

v1ではクエリ組み立てメソッド呼び出しの度に新しいインスタンスが返却されていたので、意識しなくとも途中まで作成したクエリを流用して別の複数のクエリを組み立てることが可能でした。しかしv2からはSessionメソッドの呼び出しがない場合クエリが汚染され意図しないSQLが発行されることになります。

例えばv1利用時は以下のような書き方が可能でした。

1つのベースとなるクエリを利用し個別のクエリを作成可能でした。

base := db.Model(&User{})

// SELECT * FROM `users`  WHERE (email = 'hoge@example.com')
mailQuery := base.Where("email = ?", "hoge@example.com")
mailQuery.Find(&users)

// SELECT * FROM `users`  WHERE (name = 'hoge')
nameQuery := base.Where("name = ?", "hoge")
nameQuery..Find(&users)

しかしv2で上記を実行すると。。。

base := db.Model(&User{})

// SELECT * FROM `users` WHERE email = 'hoge@example.com'
mailQuery := base.Where("email = ?", "hoge@example.com")
mailQuery.Find(&users)

// SELECT * FROM `users` WHERE email = 'hoge@example.com' AND name = 'hoge'
nameQuery := base.Where("name = ?", "hoge")
nameQuery.Find(&users)

emailを絞り込むためのWhere句がnameを絞り込むクエリにも付加されています。

これはv2からクエリの組み立て時に内部的に持っているStatementフィールドを共有するようになったためです。

v1の時と同じ結果を得たい場合は以下のように書き直す必要があります。

base := db.Model(&User{})
// SELECT * FROM `users` WHERE email = 'hoge@example.com'
mailQuery := base.Session(&gorm.Session{}).Where("email = ?", "hoge@example.com")
mailQuery.Find(&users)
// SELECT * FROM `users` WHERE name = 'hoge'
nameQuery := base.Where("name = ?", "hoge")
nameQuery.Find(&users)

新しく追加されたSessionメソッドを呼び出すことで新たらしいStatementフィールドを持つインスタンスを取得することが可能です。

v2以降では適切にSessionメソッドを呼び出して行く必要があります。

OrderByのreorder機能

v1ではOrderBy句を指定するOrderメソッドの第二引数としてオブション的にreorderフラグの設定が可能でした。

reorderを設定することで以下のように既に設定されているOrderBy句を上書くことが可能でした。

// SELECT * FROM `users`  WHERE (name = 'hoge') ORDER BY name desc
// SELECT * FROM `users`  WHERE (name = 'hoge') ORDER BY `email`
db.Model(&User{}).Where("name = ?", "hoge").Order("name desc").Find(&users).
		Order("email", true).Find(&users)

v2では第二引数が削除されており以下のように書く必要があります。

db.Model(&User{}).Where("name = ?", "hoge").Order("name desc").Find(&users).
		Order(clause.OrderByColumn{Column: clause.Column{Name: "email"}, Reorder: true}).
		Find(&users)

Rawメソッドの変更点

SQLの文字列を直接渡して実行するRawメソッドの変更点についてです。

v1ではRawメソッドにチェーンさせて1部のクエリ組み立てメソッドが併用可能でした。

例えば以下のようなことが可能でした。

// SELECT * FROM users ORDER BY `name`
db.Raw("SELECT * FROM users").Order("name")

v2ではRawを利用する際は基本的に全ての句をRawの中に記述する必要があります。

// SELECT * FROM users
-- db.Raw("SELECT * FROM users").Order("name")
// SELECT * FROM users ORDER BY name
++ db.Raw("SELECT * FROM users ORDER BY name")

*gorm.DB.New()について

v1で新しいgorm.DBインスタンスを得るために実装されていたNewメソッドがv2で削除されています。

v2ではSessionメソッドにオプションを指定することで新しいインスタンスを得ることが可能です。

-- db.New()

++ db.Session(&gorm.Session{NewDB: true})

Scopeについて

v1でTable構造体を解析した共通処理などを実装する際に何かと便利だったScopeも削除されています。

scp := db.NewScope(User{})
for _, v := range scp.Fields() {
    // column名が表示される
    fmt.Println(v.DBName)
}

v2では似たような機能はschamaパッケージに移動しているのでそちらを利用することにしました。

schm, _ := schema.Parse(User{}, &sync.Map{}, schema.NamingStrategy{})
for _, v := range schm.Fields {
    // column名が表示される
	fmt.Println(v.DBName)
}

Not found Errorの判定について

v1ではgorm.IsRecordNotFoundErrorという専用のエラー判定メソッドが用意されていました。

v2では削除されているのでerror.Isでgormに定義されているErrRecordNotFoundと直接比較するように修正しました。

-- gorm.IsRecordNotFoundError(err)
++ errors.Is(err, gorm.ErrRecordNotFound)

Countメソッドの引数の型の変更

SQLの集合関数であるCountを利用するためのCountメソッドの引数がintからint64に変更になっていました。

こちらはinterfaceに合わせてバインドさせる値の型を変更する形で対応しました。

-- func (*gorm.DB).Count(count *int) (tx *gorm.DB)

++ func (*gorm.DB).Count(count *int64) (tx *gorm.DB)

クラウドログでは下記のような内部でCountを呼び出してデータ取得とデータ数を取得するような定義がいくつかありましたがこちらは戻り値まではint64には変更しませんでした。

type HogeReporsitory interface {
   Find()([]Hoge, int, error)
}

生成されるSQLについて

生成されるSQLも若干の違いがありました。

生成されたSQLを比較するテストなどがこれらの変更で失敗していたのでv2のフォーマットに合わせて修正しました。

(おそらく下記以外にも色々あります)

Limit

// v1
db.LIMIT(10) // LIMIT 10 OFFSET 0
//v2
db.LIMIT(10) // LIMIT 10

where

// v1
db.Where("hoge = ?", str) // ....... WHERE (hoge = ?)
// v2
db.Where("hoge = ?", str) // ....... WHERE hoge = ?

タグの書き方

タグに関しては基本的にキャメルケースで書く必要があります。

また外部キー設定タグの書き方が変更になっていましたので変更に合わせて対応しました。

type User struct {
	-- ID uint `gorm:"primary_key"`
  ++ ID uint `gorm:"primaryKey"`
}

type Hoge struct {
--       Huga []Huga `gorm:"FOREIGNKEY:HogeNo;ASSOCIATION_FOREIGNKEY:HogeNo"`
++       Huga []Huga `gorm:"foreignkey:HogeNo;references:HogeNo"`
}

Closeメソッドについて

v2でDB接続を切るためのCloseメソッドが削除されています。

下記のようにgorm.DB.DB()メソッドから*sql.DBを取得可能なのでそちらを利用してCloseするように修正しました。

-- db.Close()

++ sql, err := db.DB()
++ sql.Close()

SubQueryメソッドについて

v2でSubQueryは削除され、SubQueryの呼び出しがなくてもサブクエリを作成できるようになりました。

// v1, SELECT * FROM `posts`  WHERE (user_id IN (SELECT id FROM `users`  ))
userQuery := db.Model(User{}).Select("id").SubQuery()
db.Model(Post{}).Where("user_id IN ?", userQuery).Find(&ps)

// v2, SELECT * FROM `posts` WHERE user_id IN (SELECT `id` FROM `users`)
userQuery := db.Model(User{}).Select("id")
db.Model(Post{}).Where("user_id IN (?)", userQuery).Find(&ps)

注意点としてはv2ではv1と違い()を自動でつけてくれないのでそちらも合わせて修正が必要です。

まとめ

クラウドログで行ったGormのバージョンアップ時の変更内容について紹介させていただきました。

おそらくv2での細かい変更点はまだあるかと思いますがクラウドログで行った対応としては、今回ご紹介したものがほとんどです。

この記事が少しでも誰かのお役に立てれば嬉しく思います。

以上です。

8
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
8
1