🐀

【Go】GORM v1 → v2に完全移行した時の話

2024/11/08に公開

はじめに

初めまして、株式会社ビットキー Cross Service Backend(通称CSB)チームの上窪です。

CSBチームでは、ビットキーが展開するサービス(workhub, homehub)の認証認可を支えるbitkey platform(以下、bkp)を開発・保守/運用しています。
bkpはGoで開発していて、ORMはGORMを使っています。

このGORMにはv1とv2があって、このバージョンアップを担当する機会がありました。
結果としてはbkpのGORMのバージョンを完全にv2にできているので、その時やったことを紹介します。

GORMについて

機能が豊富なことをウリにしていて、GoのORMの中では最もポピュラーです。

Goの技術イベントで他社のエンジニアの方と話しても「ウチもとりあえずGORMにしたけど困ってない」とお聞きすることが多いです。

https://gorm.io/ja_JP/

GORM v2について

GORM v1からパフォーマンスや保守性を改善するためにスクラッチでの書き直しが行われています。

v2についての公式のリリースノートはこちら。
変更点が数多く列挙されていますが、特に重要なことは「互換性のないAPIの変更」が入っていることかと思います。

https://gorm.io/ja_JP/docs/v2_release_note.html

GORM v2移行については他社様も記事を出されていますので、参考までに掲載させていただきます。

https://tech.layerx.co.jp/entry/2023/12/02/122612
https://engineer.crowdworks.jp/entry/2022/11/14/194848
https://qiita.com/yoshii0110/items/8190f5371233e73d2b30

bkpの使っているGORMのバージョンについて

bkpはマイクロサービスになっています。
REST APIサービスがworkhub,homehubからのリクエストを受け、gRPCでさらに後ろのサービスA
B・Cへと繋ぎ、このサービスA・B・CがそれぞれGORMを使ってCloud SQL(for PostgreSQL)に接続します。

以下に簡単な構成図を示します(サービスの名前は伏せます)。
bkpの構成図(簡易版)
bkpの構成図(簡易版)

サービスAはGORM v2に移行していましたが、サービスB・CはGORM v1のままになっていました。

v2に完全移行したい!

v1であることにより実害があったわけではないので、チームのタスクとして積まれていたわけではありませんでした。が、以下の点を考えてやっておきたくなりました。

  1. v1はもうメンテナンスされていないのでバグや脆弱性が発見されても対応されない
  2. v2はcontextの伝搬がある
    • ロードバランサーの通信キャンセルがDBに伝わって、長すぎるクエリを打ち切ってくれたりする
    • 発行されたSQLがトレースの繋がったログで記録される
  3. 同じライブラリを使っているのにサービスごとにバージョンがバラけていると管理がしづらい

やってみた

スカンクワーク[1]の時間に進めて、変更がリリースされるまでおおよそ2ヶ月かかりました。

進め方

  1. importの "github.com/jinzhu/gorm""gorm.io/gorm" に置換して go build し、落ちたところを直す
  2. 単体テストを実行して落ちたところを直す
  3. E2Eテストを実行して落ちたところを直す

変更点

大半のコードはそのまま動いたのですが、やはり修正が必要な部分はあったのでピックアップして紹介します。

コネクションの開け方

コネクションを開くための func Open のインターフェースが変わっています。
v1ではDSNを直接受け取っていましたが、v2ではGORMが提供しているドライバーを使って初期化した type Dialector を受け取るようになっていました。

また、func (*DB) DB がエラーを返すようになっているので、コネクションの設定など行いたい場合は一度変数に格納してから行う必要があります。

v1
import (
	"database/sql"

	_ "github.com/lib/pq"
	"github.com/jinzhu/gorm"
)

const dsn = "host=db-postgres port=5432 user=postgres dbname=service_b sslmode=disable timezone=UTC"

db, err := gorm.Open("postgres", dsn)
if err != nil {
	return gxerrors.Wrap(err, "failed to open Read-Only connection.")
}

// コネクション数の設定
db.DB().SetMaxIdleConns(4)
db.DB().SetMaxOpenConns(4)
v2
import (
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

const dsn = "host=db-postgres port=5432 user=postgres dbname=service_b sslmode=disable timezone=UTC"

db, err = gorm.Open(postgres.Open(dsn))
if err != nil {
	return gxerrors.Wrap(err, "failed to open Read-Only connection.")
}

sqlDB, err := db.DB()
if err != nil {
	return gxerrors.WithStack(err)
}

// コネクション数の設定
sqlDB.SetMaxIdleConns(4)
sqlDB.SetMaxOpenConns(4)
参考

コネクションの閉じ方

コネクションを閉じるメソッドが type DB から削除され、標準のdb/sqlパッケージの func (*DB) Close を使ってコネクションを閉じるようになりました。

v1
if err := db.Close(); err != nil {
	logger.Error(err, `Failed to close connection`)
}
v2
sqlDB, err := db.DB()
if err != nil {
	logger.Error(err, `Failed to get sql.DB`)
}

if err := sqlDB.Close(); err != nil {
	logger.Error(err, `Failed to close connection`)
}
参考

Loggerの設定

Open() で受け取った type DB をレシーバーとして func (*DB) SetLogger を呼び出して設定する方式から、 Open() の引数に type Interface を満たすLoggerの構造体を渡す方式になりました。

v1
import (
	"github.com/rs/zerolog/log"
)

const dsn = "host=db-postgres port=5432 user=postgres dbname=service_b sslmode=disable timezone=UTC"

db, err := gorm.Open("postgres", dsn)
db.LogMode(true)
db.SetLogger(&log.Logger)
v2
const dsn = "host=db-postgres port=5432 user=postgres dbname=service_b sslmode=disable timezone=UTC"

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

特に設定しないとGORMのデフォルトのLoggerが使用されますが、CSBチームでは独自のLoggerライブラリを管理して一本化していたので、これをラップするように logger.Interface を満たす型を定義し、 Open() へ渡すようにしました。

postgres/logger.go
import (
    "gorm.io/gorm/logger"
)

type gormLogger struct {
	logger.Config
}

func newGormLogger() logger.Interface {
	return &gormLogger{
		Config: logger.Config{
			SlowThreshold:             100 * time.Millisecond, // 2024年でのCSBチームでの基準
			Colorful:                  false,                  // Cloud Loggingに出力されるとログのカラーは関係なくなるため無効化
			IgnoreRecordNotFoundError: false,
			ParameterizedQueries:      false,
			LogLevel:                  logger.Warn,
		},
	}
}

// Info implements logger.Interface.
func (g *gormLogger) Info(ctx context.Context, format string, data ...interface{}) {
	if g.LogLevel >= logger.Info {
		// CSBチーム管理のLoggerライブラリをラップ
		mylogger.Infof(ctx, format, data...)
	}
}
参考

エラーハンドリングの方法

レコードがヒットしなかった時のエラーハンドリングとしてv1ではgorm.IsRecordNotFoundError という関数がありましたが、v2では廃止されていました。

v1
if gorm.IsRecordNotFoundError(err) {
	return fmt.Errorf("Hoge not found: %w", err)
}
v2
if errors.Is(err, gorm.ErrRecordNotFound) {
	return fmt.Errorf("Hoge not found: %w", err)
}

このようにerrors.Isで判定してあげる必要があります。

参考

.Limit()Offset() に渡す型

v1では interface{} ですが、v2では int を受け取るようになっています。

bkpはLIMIT, OFFSETを下記のようなドメインオブジェクトとして定義してusecase ↔ repositoryでやりとりしていたので多少影響はありました。

package domain

// Pagination is an object for select operation condition.
type Pagination struct {
	Offset uint32
	Limit  uint32
}
v1
package repository

func SelectHogesByStatus(status domain.Status, pagination domain.Pagination) ([]*domain.HogeEntity, error) {
	var records []*domain.HogeEntity
	if err := db.Where(`status = ? AND NOT deleted`, status.Val()).
		Limit(pagination.Limit).
		Offset(pagination.Offset).
		Find(&records).Error; err != nil {
		return nil, err
	}

	return records, nil
}
v2
package repository

func SelectHogesByStatus(status domain.Status, pagination domain.Pagination) ([]*domain.HogeEntity, error) {
	var records []*domain.HogeEntity
	if err := db.WithContext(ctx).
		Where(`status = ? AND NOT deleted`, status.Val()).
		Limit(int(pagination.Limit)).
		Offset(int(pagination.Offset)).
		Find(&records).Error; err != nil {
		return nil, err
	}

	return records, nil
}

ドメインの方の型を int に変える手もありましたが、int にしないといけないのはデータアクセス上の都合なので、repositoryの方でキャストするのが妥当かつ楽かと思い、こうしました。

参考

byteaのゼロ値

postgresのbytea型のカラムにnilを入れようとした時の挙動がv1とv2では若干異なることがわかりました。
カラムに NOT NULL 制約がついている場合、v1ではエラーが発生しませんが、v2だとエラーが発生します。

なぜそうなるのかが読み取れるソースコードやドキュメントなどは見つけられていないので、以下にテストコードを書いて示します。
(もし原因をご存知の方がいらっしゃいましたら教えていただけると嬉しいです 🙇)

v1の挙動:
https://github.com/daikideal/gorm-demo/blob/main/bytea/v1.go#L10-L21
https://github.com/daikideal/gorm-demo/blob/main/bytea/v1_test.go#L39-L57

v2の挙動:
https://github.com/daikideal/gorm-demo/blob/main/bytea/v2.go#L10-L21
https://github.com/daikideal/gorm-demo/blob/main/bytea/v2_test.go#L43-L73

結果
go test -v -count=1 ./...
=== RUN   TestInsertWithV1
=== RUN   TestInsertWithV1/`data`に値がセットされている場合、Insertできる
=== RUN   TestInsertWithV1/`data`空のバイト列がセットされている場合、Insertできる
=== RUN   TestInsertWithV1/`data`にnilがセットされている場合、Insertできる
--- PASS: TestInsertWithV1 (0.09s)
    --- PASS: TestInsertWithV1/`data`に値がセットされている場合、Insertできる (0.04s)
    --- PASS: TestInsertWithV1/`data`空のバイト列がセットされている場合、Insertできる (0.02s)
    --- PASS: TestInsertWithV1/`data`にnilがセットされている場合、Insertできる (0.02s)
=== RUN   TestInsertWithV2
=== RUN   TestInsertWithV2/`data`に値がセットされている場合、Insertできる
=== RUN   TestInsertWithV2/`data`空のバイト列がセットされている場合、Insertできる
=== RUN   TestInsertWithV2/`data`にnilがセットされている場合、NOT_NULL制約エラーが発生する

2024/10/25 17:58:15 /Users/daikiuekubo/workspace/gorm-demo/bytea/v2.go:16 ERROR: null value in column "data" of relation "bytea_samples" violates not-null constraint (SQLSTATE 23502)
[3.871ms] [rows:0] INSERT INTO "bytea_samples" ("id","data") VALUES ('f9521b26-86b7-46d0-8234-b3f3dfb1fc55','')
--- PASS: TestInsertWithV2 (0.09s)
    --- PASS: TestInsertWithV2/`data`に値がセットされている場合、Insertできる (0.03s)
    --- PASS: TestInsertWithV2/`data`空のバイト列がセットされている場合、Insertできる (0.03s)
    --- PASS: TestInsertWithV2/`data`にnilがセットされている場合、NOT_NULL制約エラーが発生する (0.04s)
PASS
ok  	github.com/daikideal/gorm-demo/bytea	0.669s

検証した結果、nilをセットした時と[]byte{}をセットした時でpostgresにInsertされる値は変わらない(どちらも\xになる)ことがわかったので、以下のように書き換えて対応しました。

v1
type HugaEntity struct {
    ID   string
    Data []byte
}

if err := db.Create(&HugaEntity{
    ID:   "xxxx-xxxx-xxxx",
    Data: nil,
}).Error; err != nil {
    return err
}
v2
type HugaEntity struct {
    ID   string
    Data []byte
}

if err := db.Create(&HugaEntity{
    ID:   "xxxx-xxxx-xxxx",
    Data: []byte{},
}).Error; err != nil {
    return err
}

やってみて

ちょっと躓くポイントもありましたが、そこまで苦労はしませんでした。

要因としては、そもそもクエリビルダーとしてのシンプルな機能しか使っていなかったというのもありますが、単体テスト・E2Eテストが充実していたのが大きかったです。

E2Eテストは、CSBチームが継続的にメンテナンスして毎日自動実行しているものであり、このテストでbkpが提供しているAPIの動作担保を行っています。
もしかしたら、認識できてない箇所で発行されるSQLに多少の差分が出ていたりする可能性はありますが、E2Eテストが通っていれば、bkpの直接のユーザー(workhub, homehubの開発者)、およびworkhub, homehubを利用するお客様の体験に悪い影響が出ていないと判断できます。

おわりに

テストが整備されている環境のおかげで大胆な変更も安心して進められました。
単体テスト・E2Eテストがなければもっと時間がかかっていたと思います。

もし開発チームを1から立ち上げる機会があるとしたら、こういった動作担保の仕組みが初期の方で整えられるといいなと思っています。

(あとはあまりサードパーティツールの機能を頑張って使ったりせずシンプルな使い方に留めておくと剥がしたり破壊的変更に対応する時楽…かも?)

脚注
  1. 社員が本来やるべき業務以外の自主的活動。Google社の20%ルールと似ており、1週間スプリントのCSBチームでは毎週金曜日が該当する ↩︎

Bitkey Developers

Discussion