【Go】GORM v1 → v2に完全移行した時の話
はじめに
初めまして、株式会社ビットキー 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にしたけど困ってない」とお聞きすることが多いです。
GORM v2について
GORM v1からパフォーマンスや保守性を改善するためにスクラッチでの書き直しが行われています。
v2についての公式のリリースノートはこちら。
変更点が数多く列挙されていますが、特に重要なことは「互換性のないAPIの変更」が入っていることかと思います。
GORM v2移行については他社様も記事を出されていますので、参考までに掲載させていただきます。
bkpの使っているGORMのバージョンについて
bkpはマイクロサービスになっています。
REST APIサービスがworkhub,homehubからのリクエストを受け、gRPCでさらに後ろのサービスA
B・Cへと繋ぎ、このサービスA・B・CがそれぞれGORMを使ってCloud SQL(for PostgreSQL)に接続します。
以下に簡単な構成図を示します(サービスの名前は伏せます)。
bkpの構成図(簡易版)
サービスAはGORM v2に移行していましたが、サービスB・CはGORM v1のままになっていました。
v2に完全移行したい!
v1であることにより実害があったわけではないので、チームのタスクとして積まれていたわけではありませんでした。が、以下の点を考えてやっておきたくなりました。
- v1はもうメンテナンスされていないのでバグや脆弱性が発見されても対応されない
- v2はcontextの伝搬がある
- ロードバランサーの通信キャンセルがDBに伝わって、長すぎるクエリを打ち切ってくれたりする
- 発行されたSQLがトレースの繋がったログで記録される
- 同じライブラリを使っているのにサービスごとにバージョンがバラけていると管理がしづらい
やってみた
スカンクワーク[1]の時間に進めて、変更がリリースされるまでおおよそ2ヶ月かかりました。
進め方
- importの
"github.com/jinzhu/gorm"
を"gorm.io/gorm"
に置換してgo build
し、落ちたところを直す - 単体テストを実行して落ちたところを直す
- E2Eテストを実行して落ちたところを直す
変更点
大半のコードはそのまま動いたのですが、やはり修正が必要な部分はあったのでピックアップして紹介します。
コネクションの開け方
コネクションを開くための func Open
のインターフェースが変わっています。
v1ではDSNを直接受け取っていましたが、v2ではGORMが提供しているドライバーを使って初期化した type Dialector
を受け取るようになっていました。
また、func (*DB) DB
がエラーを返すようになっているので、コネクションの設定など行いたい場合は一度変数に格納してから行う必要があります。
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)
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)
参考
v1:
v2:コネクションの閉じ方
コネクションを閉じるメソッドが type DB
から削除され、標準のdb/sqlパッケージの func (*DB) Close
を使ってコネクションを閉じるようになりました。
if err := db.Close(); err != nil {
logger.Error(err, `Failed to close connection`)
}
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`)
}
参考
v1: v2:
Loggerの設定
Open()
で受け取った type DB
をレシーバーとして func (*DB) SetLogger
を呼び出して設定する方式から、 Open()
の引数に type Interface
を満たすLoggerの構造体を渡す方式になりました。
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)
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()
へ渡すようにしました。
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:
v2:エラーハンドリングの方法
レコードがヒットしなかった時のエラーハンドリングとしてv1ではgorm.IsRecordNotFoundError
という関数がありましたが、v2では廃止されていました。
if gorm.IsRecordNotFoundError(err) {
return fmt.Errorf("Hoge not found: %w", err)
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("Hoge not found: %w", err)
}
このようにerrors.Is
で判定してあげる必要があります。
参考
v1:
v2:
.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
}
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
}
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の方でキャストするのが妥当かつ楽かと思い、こうしました。
参考
v1:
v2:byteaのゼロ値
postgresのbytea
型のカラムにnilを入れようとした時の挙動がv1とv2では若干異なることがわかりました。
カラムに NOT NULL
制約がついている場合、v1ではエラーが発生しませんが、v2だとエラーが発生します。
なぜそうなるのかが読み取れるソースコードやドキュメントなどは見つけられていないので、以下にテストコードを書いて示します。
(もし原因をご存知の方がいらっしゃいましたら教えていただけると嬉しいです 🙇)
v1の挙動:
v2の挙動:
結果
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
になる)ことがわかったので、以下のように書き換えて対応しました。
type HugaEntity struct {
ID string
Data []byte
}
if err := db.Create(&HugaEntity{
ID: "xxxx-xxxx-xxxx",
Data: nil,
}).Error; err != nil {
return err
}
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から立ち上げる機会があるとしたら、こういった動作担保の仕組みが初期の方で整えられるといいなと思っています。
(あとはあまりサードパーティツールの機能を頑張って使ったりせずシンプルな使い方に留めておくと剥がしたり破壊的変更に対応する時楽…かも?)
-
社員が本来やるべき業務以外の自主的活動。Google社の20%ルールと似ており、1週間スプリントのCSBチームでは毎週金曜日が該当する ↩︎
Discussion