フューチャー技術ブログ

GORM v1 と v2 のソースコードリーディングしてみた

gormトップページ [GORM v2 doc](https://gorm.io/) より

概要

TIG DXユニット 多賀です。GoのORマッパー連載の4日目の記事です。

GORM の v1 と v2 の実装を比較して、何が変わっているのかを調査してみました。
v1 -> v2 への移行や、詳細な変更点については別の記事を見ていただいたほうが良いかと思います。

当記事では、ソースコードの差分を眺めてみてなにか学びがないかを調べてみた記事になっています。
完全にスクラッチで書き直しているとのことで、エッセンスが吸収できると良いなと思っています。

調査バージョン

バージョン リポジトリ タグ
v1 jinzhu/gorm v1.9.16
v2 go-gorm/gorm v1.21.11

ディレクトリ構造

まずはディレクトリ構造の差分を比較してみます。

v1
❯ tree -L 1 --dirsfirst
.
├── dialects
├── License
├── README.md
├── association.go
├── association_test.go
├── callback.go
├── callback_create.go
├── callback_delete.go
├── callback_query.go
├── callback_query_preload.go
├── callback_row_query.go
├── callback_save.go
├── callback_system_test.go
├── callback_update.go
├── callbacks_test.go
├── create_test.go
├── customize_column_test.go
├── delete_test.go
├── dialect.go
├── dialect_common.go
├── dialect_mysql.go
├── dialect_postgres.go
├── dialect_sqlite3.go
├── docker-compose.yml
├── embedded_struct_test.go
├── errors.go
├── errors_test.go
├── field.go
├── field_test.go
├── go.mod
├── go.sum
├── interface.go
├── join_table_handler.go
├── join_table_test.go
├── logger.go
├── main.go
├── main_test.go
├── migration_test.go
├── model.go
├── model_struct.go
├── model_struct_test.go
├── multi_primary_keys_test.go
├── naming.go
├── naming_test.go
├── pointer_test.go
├── polymorphic_test.go
├── preload_test.go
├── query_test.go
├── scaner_test.go
├── scope.go
├── scope_test.go
├── search.go
├── search_test.go
├── test_all.sh
├── update_test.go
├── utils.go
└── wercker.yml

1 directory, 56 files
v2
❯ tree -L 1 --dirsfirst
.
├── callbacks
├── clause
├── logger
├── migrator
├── schema
├── tests
├── utils
├── License
├── README.md
├── association.go
├── callbacks.go
├── chainable_api.go
├── errors.go
├── finisher_api.go
├── go.mod
├── go.sum
├── gorm.go
├── interfaces.go
├── migrator.go
├── model.go
├── prepare_stmt.go
├── scan.go
├── soft_delete.go
├── statement.go
└── statement_test.go

7 directories, 18 files

v1 ではパッケージが切られていない設計に対して、v2 ではパッケージを分けた設計に変更されています。
callbacks_xxx.gocallbacks パッケージにまとめられていそうですが、その他の実装がどのように変更されたかはディレクトリ構造を見るだけではわからないですね。

gorm.Open

GORM 利用時は、 gorm.Open 関数を利用して database/sql パッケージの sql.DB をラップした GORM 向けの gorm.DB オブジェクトを取得します。取得のインターフェイス含めて何が変わっているのでしょうか?

API を見てみると、インターフェイス自体がまず変わっていて、第一引数の dialect を文字列ではなく gorm.Dialector で受けるようになっています。なので、 "postgres""mysql" の文字列指定ができなくなっていますね。

v1
func Open(dialect string, args ...interface{}) (db *DB, err error)
v2
func Open(dialector Dialector, opts ...Option) (db *DB, err error)

gorm.Dialector を見てみると、 interface が定義されています。

v2
// https://pkg.go.dev/gorm.io/gorm#Dialector

// Dialector GORM database dialector
type Dialector interface {
Name() string
Initialize(*DB) error
Migrator(db *DB) Migrator
DataTypeOf(*schema.Field) string
DefaultValueOf(*schema.Field) clause.Expression
BindVarTo(writer clause.Writer, stmt *Statement, v interface{})
QuoteTo(clause.Writer, string)
Explain(sql string, vars ...interface{}) string
}

Dialector interface の実装ですが、ドキュメントを見てみると別リポジトリでされていることがわかりました。各 DB driver 毎に dialector が実装されています。

(BigQuery 向けの dialector が実装されているのが意外でした。)
使い方としては、 各パッケージにて Open 関数が定義されているようでそちらを呼び出して、各 DB ごとの dialector を取得します。 (※ module 名がリポジトリ URL と異なるので注意が必要です。)

v2
// sqlite

import(
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)

dialector := sqlite.Open("gorm.db")
db, err := gorm.Open(dialector, &gorm.Config{})

v1 と異なり、利用者側で driver を blank import しなくて良くなりました。 GORM が提供する dialector の実装内で既に定義されているためです。それぞれの dialector の実装を見たところ、 Postgres の driver が jackc/pgx になっていた点が意外でした ( lib/pq をよく使っていました )。
driver を変更したい場合は、 gorm.Dialector interface を実装する必要があり、少し選択の自由度が下がってますね。


余談
jackc/pgxdatabase/sql と 独自のインターフェイスのどちらも対応している点が lib/pq と異なり、独自のインターフェイスではより Postgres の特徴を利用できる模様です。


第2引数以降の指定も変更されています。 Functional options パターンが使われるようになっていますね。

v1
func Open(dialect string, args ...interface{}) (db *DB, err error)
↑ この部分
v2
func Open(dialector Dialector, opts ...Option) (db *DB, err error)
↑ この部分

Option は interface になっています。 Apply(*Config) error が適用される option です。

v2
type Option interface {
Apply(*Config) error
AfterInitialize(*DB) error
}

gorm.Open のAPI 変更は、全体的に型付けを厳格化して Open の実装ミスをコンパイル時にある程度検知できるように、設計変更されていると感じました。

ソースコードの面でも、インターフェイスの変更に伴い、更新が入っています。

[v1 gorm.Open](https://github.com/jinzhu/gorm/blob/v1.9.16/main.go#L58)
func Open(dialect string, args ...interface{}) (db *DB, err error) {
if len(args) == 0 {
err = errors.New("invalid database source")
return nil, err
}
var source string
var dbSQL SQLCommon
var ownDbSQL bool

switch value := args[0].(type) {
case string:
var driver = dialect
if len(args) == 1 {
source = value
} else if len(args) >= 2 {
driver = value
source = args[1].(string)
}
dbSQL, err = sql.Open(driver, source)
ownDbSQL = true
case SQLCommon:
dbSQL = value
ownDbSQL = false
default:
return nil, fmt.Errorf("invalid database source: %v is not a valid type", value)
}

db = &DB{
db: dbSQL,
logger: defaultLogger,
callbacks: DefaultCallback,
dialect: newDialect(dialect, dbSQL),
}
db.parent = db
if err != nil {
return
}
// Send a ping to make sure the database connection is alive.
if d, ok := dbSQL.(*sql.DB); ok {
if err = d.Ping(); err != nil && ownDbSQL {
d.Close()
}
}
return
}
[v2 gorm.Open](https://github.com/go-gorm/gorm/blob/v1.21.11/gorm.go#L112)
func Open(dialector Dialector, opts ...Option) (db *DB, err error) {
config := &Config{}

sort.Slice(opts, func(i, j int) bool {
_, isConfig := opts[i].(*Config)
_, isConfig2 := opts[j].(*Config)
return isConfig && !isConfig2
})

for _, opt := range opts {
if opt != nil {
if err := opt.Apply(config); err != nil {
return nil, err
}
defer func(opt Option) {
if errr := opt.AfterInitialize(db); errr != nil {
err = errr
}
}(opt)
}
}

if d, ok := dialector.(interface{ Apply(*Config) error }); ok {
if err = d.Apply(config); err != nil {
return
}
}

if config.NamingStrategy == nil {
config.NamingStrategy = schema.NamingStrategy{}
}

if config.Logger == nil {
config.Logger = logger.Default
}

if config.NowFunc == nil {
config.NowFunc = func() time.Time { return time.Now().Local() }
}

if dialector != nil {
config.Dialector = dialector
}

if config.Plugins == nil {
config.Plugins = map[string]Plugin{}
}

if config.cacheStore == nil {
config.cacheStore = &sync.Map{}
}

db = &DB{Config: config, clone: 1}

db.callbacks = initializeCallbacks(db)

if config.ClauseBuilders == nil {
config.ClauseBuilders = map[string]clause.ClauseBuilder{}
}

if config.Dialector != nil {
err = config.Dialector.Initialize(db)
}

preparedStmt := &PreparedStmtDB{
ConnPool: db.ConnPool,
Stmts: map[string]Stmt{},
Mux: &sync.RWMutex{},
PreparedSQL: make([]string, 0, 100),
}
db.cacheStore.Store(preparedStmtDBKey, preparedStmt)

if config.PrepareStmt {
db.ConnPool = preparedStmt
}

db.Statement = &Statement{
DB: db,
ConnPool: db.ConnPool,
Context: context.Background(),
Clauses: map[string]clause.Clause{},
}

if err == nil && !config.DisableAutomaticPing {
if pinger, ok := db.ConnPool.(interface{ Ping() error }); ok {
err = pinger.Ping()
}
}

if err != nil {
config.Logger.Error(context.Background(), "failed to initialize database, got error %v", err)
}

return
}

第一に、Open の返却値である DB struct のフィールド構成が大きく変更されています。

v1
type DB struct {
sync.RWMutex
Value interface{}
Error error
RowsAffected int64

// single db
db SQLCommon
blockGlobalUpdate bool
logMode logModeValue
logger logger
search *search
values sync.Map

// global db
parent *DB
callbacks *Callback
dialect Dialect
singularTable bool

// function to be used to override the creating of a new timestamp
nowFuncOverride func() time.Time
}
v2
type DB struct {
*Config
Error error
RowsAffected int64
Statement *Statement
clone int
}

v2 では 設定値が Config struct の埋め込みで表現されていて、設定値のフィールド項目がわかりやすくなっています。また先程の、 Option interface を Config struct が満たしているため、設定値をまとめて渡すことができるようになっています。

v2
db, err := gorm.Open(dialector, &gorm.Config{})

v1, v2 とも sql.DB をラップしているのですが、 struct をぱっと見ただけではどこに持っているのかわからないです。実態はこちらです。

v1
type DB struct {
db SQLCommon // *sql.DB
...
v2
type Config struct {
ConnPool ConnPool // *sql.DB
...

どちらも、 sql.DB を満たす interface が定義されているのですが、interface 定義も少し改良が加えられています。 v2 では Context 対応のメソッドを利用するように変更されていて、 Context に正式に対応していることがわかります。 database/sql のインターフェイスは以下の 4メソッドだけしか利用されていないのも少々驚きました。(正確には、Transaction 系のメソッドも利用されています。 別で TxBeginner TxCommitter interface が GORM 内で定義されており、型変換により dabase/sql の各 Transaction 系のメソッドを呼び出していました。)

v1
type SQLCommon interface {
Exec(query string, args ...interface{}) (sql.Result, error)
Prepare(query string) (*sql.Stmt, error)
Query(query string, args ...interface{}) (*sql.Rows, error)
QueryRow(query string, args ...interface{}) *sql.Row
}
v2
type ConnPool interface {
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
}

ちなみに、sql.DB の生成については、 v1 は直接 sql.Open を呼び出しているのですが、 v2 では gorm.Dialector.Initialize() を経由して、 GORM が提供している driver 内で sql.Open を呼び出しています。

参考: https://github.com/go-gorm/sqlite/blob/master/sqlite.go#L47

エッセンス

  • interface を利用して型付けを厳格にして実行時エラーを防御
  • 任意の項目は Functional options パターンで設定できるようにすると良い
  • config 値は、struct として定義して埋め込みで定義することで、設定値と struct で利用するフィールドを分離
  • 標準API から必要なメソッドのみを、抜き出して interface 定義することで、利用するメソッドを絞り込む

(おまけ) Prepared Statement

v2 では Prepared Statement モードに対応しています。 gorm.Open 内で実装箇所がありましたので、併せて調べてみます。
ちなみに、 v1 の SQLCommon 上は Prepare() の呼び出しに対応していますが、検索したところ実装上は呼ばれていなかったので Prepared Statement は使えなかった状態と考えられます。
v2 では、 gorm.Open() の呼び出し時の optsgorm.Config{PrepareStmt: true} と指定することで利用できます。

実装としては、 gorm.PreparedStmtDB structをキャッシュで持ち、 ConnPool (= sql.DB) と差し替えを実施しています。

func Open(dialector Dialector, opts ...Option) (db *DB, err error) {
...
preparedStmt := &PreparedStmtDB{
ConnPool: db.ConnPool,
Stmts: map[string]Stmt{},
Mux: &sync.RWMutex{},
PreparedSQL: make([]string, 0, 100),
}
db.cacheStore.Store(preparedStmtDBKey, preparedStmt)

if config.PrepareStmt {
// db.ConnPool を prepared statement 対応版へ差し替え
db.ConnPool = preparedStmt
}

db.Statement = &Statement{
DB: db,
ConnPool: db.ConnPool,
Context: context.Background(),
Clauses: map[string]clause.Clause{},
}

PreparedStmtDB struct にて prepare された Stmt を管理して、クエリ実行時に prepare されているかキャッシュ ( Stmts フィールド) を検索して利用しています。

gorm/prepare_stmt.go at v1.21.11 · go-gorm/gorm

v2
type PreparedStmtDB struct {
Stmts map[string]Stmt // Stmt キャッシュ
PreparedSQL []string
Mux *sync.RWMutex
ConnPool
}

type Stmt struct {
*sql.Stmt // database/sql 標準を利用
Transaction bool
}

// Query の場合
func (db *PreparedStmtDB) QueryContext(ctx context.Context, query string, args ...interface{}) (rows *sql.Rows, err error) {
stmt, err := db.prepare(ctx, db.ConnPool, false, query) // ここで Stmt キャッシュを検索
if err == nil {
rows, err = stmt.QueryContext(ctx, args...)
if err != nil {
db.Mux.Lock()
stmt.Close()
delete(db.Stmts, query)
db.Mux.Unlock()
}
}
return rows, err
}

クエリ発行

クエリ発行の比較として、先頭一行を SELECT する First() 関数の実装を読んでみます。

v1: gorm/main.go#First

v1
func (s *DB) First(out interface{}, where ...interface{}) *DB {
newScope := s.NewScope(out)
newScope.Search.Limit(1)

return newScope.Set("gorm:order_by_primary_key", "ASC").
inlineCondition(where...).callCallbacks(s.parent.callbacks.queries).db
}

v2: gorm/finisher_api.go#First

v2
func (db *DB) First(dest interface{}, conds ...interface{}) (tx *DB) {
tx = db.Limit(1).Order(clause.OrderByColumn{
Column: clause.Column{Table: clause.CurrentTable, Name: clause.PrimaryKey},
})
if len(conds) > 0 {
if exprs := tx.Statement.BuildCondition(conds[0], conds[1:]...); len(exprs) > 0 {
tx.Statement.AddClause(clause.Where{Exprs: exprs})
}
}
tx.Statement.RaiseErrorOnNotFound = true
tx.Statement.Dest = dest
return tx.callbacks.Query().Execute(tx)
}

API のインターフェイスは変わっていないですが、引数の命名が変更されています。

// sql の結果の出力先は destination と名付けられている模様です
out -> dest

// condition へ命名を統一している模様です
// v1 から inlineCondition 等で condition を使っているため
where -> conds

実装を読むと、v1 は Scope struct を利用して SQL を実行していたのに対して、v2 では特に Scope struct は利用せず gorm.DB を tx 変数へ格納の上で、そのまま利用しています。
そもそも v1 の Scope はどういった利用用途であったかを調べてみると、 Scope のコメントにあるように実行する特定のクエリ操作の状態のみを含むオブジェクト、を指している模様です。 First() で呼び出している db.NewScope() メソッドを見ると、 gorm.DB を clone して Scope へ渡しておりクエリ発行毎に Scope を生成していることがわかります。

v1
// Scope contain current operation's information when you perform any operation on the database
type Scope struct {
...

// NewScope create a scope for current operation
func (s *DB) NewScope(value interface{}) *Scope {
dbClone := s.clone()
dbClone.Value = value
scope := &Scope{db: dbClone, Value: value}
if s.search != nil {
scope.Search = s.search.clone()
} else {
scope.Search = &search{}
}
return scope
}

v2 では、 First() 内で直接呼び出してはないですが、 First() で呼び出している Limit()Order() 内の gorm.DB.getInstance() メソッドで同様の処理をしています。
v2 では gorm.DB をそのままコピーして利用しつつ、Statement をクエリ発行毎に 発行 or clone しています。

v2
func (db *DB) Limit(limit int) (tx *DB) {
tx = db.getInstance() // この部分
tx.Statement.AddClause(clause.Limit{Limit: limit})
return
}

func (db *DB) getInstance() *DB {
if db.clone > 0 {
tx := &DB{Config: db.Config, Error: db.Error}

if db.clone == 1 {
// clone with new statement
tx.Statement = &Statement{
DB: tx,
ConnPool: db.Statement.ConnPool,
Context: db.Statement.Context,
Clauses: map[string]clause.Clause{},
Vars: make([]interface{}, 0, 8),
}
} else {
// with clone statement
tx.Statement = db.Statement.clone()
tx.Statement.DB = tx
}

return tx
}

return db
}

Statement の定義は以下です。 scopes はフィールドで持つ構造になっています。

v2
type Statement struct {
*DB
TableExpr *clause.Expr
Table string
Model interface{}
Unscoped bool
Dest interface{}
ReflectValue reflect.Value
Clauses map[string]clause.Clause
BuildClauses []string
Distinct bool
Selects []string // selected columns
Omits []string // omit columns
Joins []join
Preloads map[string][]interface{}
Settings sync.Map
ConnPool ConnPool
Schema *schema.Schema
Context context.Context
RaiseErrorOnNotFound bool
SkipHooks bool
SQL strings.Builder
Vars []interface{}
CurDestIndex int
attrs []interface{}
assigns []interface{}
scopes []func(*DB) *DB
}

Scope を生成しているところから、Statement へ変更されていますが、実態としては大きな変更は入っていない印象でした。
(データモデルやインターフェイスは変わっていますが、やっていることはあまり変わっていないため。)

続いて実際のクエリ発行と、model への適用はどこでやっているのでしょうか。
v1, v2 ともにレコード取得は以下のメソッド呼び出しで完結しています。

v1&v2
// v1 と v2 どちらも同様
db.First(&product, 1)

v1 から見てみると、First メソッド内のどこかしらでクエリ発行が行われているはずですが、実装を見ても正直良くわからないです。

v1
// v1 First()
func (s *DB) First(out interface{}, where ...interface{}) *DB {
newScope := s.NewScope(out)
newScope.Search.Limit(1)

return newScope.Set("gorm:order_by_primary_key", "ASC").
inlineCondition(where...).callCallbacks(s.parent.callbacks.queries).db
}

おそらく、 callCallbacks にて実行されていると推測しましたが、実装をみると引数で渡された関数を呼び出しているのみでした。

v1
func (scope *Scope) callCallbacks(funcs []*func(s *Scope)) *Scope {
defer func() {
if err := recover(); err != nil {
if db, ok := scope.db.db.(sqlTx); ok {
db.Rollback()
}
panic(err)
}
}()
for _, f := range funcs {
(*f)(scope)
if scope.skipLeft {
break
}
}
return scope
}

callCallbacks の引数である、s.parent.callbacks.queries にクエリを実行する関数が渡っていそうなので、どこで定義しているか調べてみると、 gorm.Open にて DefaultCallback を渡していました。

v1
func Open(dialect string, args ...interface{}) (db *DB, err error) {
...
db = &DB{
db: dbSQL,
logger: defaultLogger,
callbacks: DefaultCallback,
dialect: newDialect(dialect, dbSQL),
}
db.parent = db

さらに、 DefaultCallback をみると、 Callback struct が格納されているだけで、 queries フィールドが初期化されていません。

v1
// callback.go
var DefaultCallback = &Callback{logger: nopLogger{}} // 初期化されていない..?

どこかで初期化しているところはないか、調べてみると init() が利用されてました。 init() が利用されていると、ソースコードが追いづらくて、読みづらかったです。

v1
// callback_query.go
func init() {
DefaultCallback.Query().Register("gorm:query", queryCallback)
DefaultCallback.Query().Register("gorm:preload", preloadCallback)
DefaultCallback.Query().Register("gorm:after_query", afterQueryCallback)
}

クエリ発行の実態は、 Register() で渡されている queryCallback 関数でした。

[v1 gorm.queryCallback](https://github.com/jinzhu/gorm/blob/v1.9.16/callback_query.go#L17)
v1
func queryCallback(scope *Scope) {
if _, skip := scope.InstanceGet("gorm:skip_query_callback"); skip {
return
}

//we are only preloading relations, dont touch base model
if _, skip := scope.InstanceGet("gorm:only_preload"); skip {
return
}

defer scope.trace(NowFunc())

var (
isSlice, isPtr bool
resultType reflect.Type
results = scope.IndirectValue()
)

if orderBy, ok := scope.Get("gorm:order_by_primary_key"); ok {
if primaryField := scope.PrimaryField(); primaryField != nil {
scope.Search.Order(fmt.Sprintf("%v.%v %v", scope.QuotedTableName(), scope.Quote(primaryField.DBName), orderBy))
}
}

if value, ok := scope.Get("gorm:query_destination"); ok {
results = indirect(reflect.ValueOf(value))
}

if kind := results.Kind(); kind == reflect.Slice {
isSlice = true
resultType = results.Type().Elem()
results.Set(reflect.MakeSlice(results.Type(), 0, 0))

if resultType.Kind() == reflect.Ptr {
isPtr = true
resultType = resultType.Elem()
}
} else if kind != reflect.Struct {
scope.Err(errors.New("unsupported destination, should be slice or struct"))
return
}

scope.prepareQuerySQL()

if !scope.HasError() {
scope.db.RowsAffected = 0

if str, ok := scope.Get("gorm:query_hint"); ok {
scope.SQL = fmt.Sprint(str) + scope.SQL
}

if str, ok := scope.Get("gorm:query_option"); ok {
scope.SQL += addExtraSpaceIfExist(fmt.Sprint(str))
}

if rows, err := scope.SQLDB().Query(scope.SQL, scope.SQLVars...); scope.Err(err) == nil {
defer rows.Close()

columns, _ := rows.Columns()
for rows.Next() {
scope.db.RowsAffected++

elem := results
if isSlice {
elem = reflect.New(resultType).Elem()
}

scope.scan(rows, columns, scope.New(elem.Addr().Interface()).Fields())

if isSlice {
if isPtr {
results.Set(reflect.Append(results, elem.Addr()))
} else {
results.Set(reflect.Append(results, elem))
}
}
}

if err := rows.Err(); err != nil {
scope.Err(err)
} else if scope.db.RowsAffected == 0 && !isSlice {
scope.Err(ErrRecordNotFound)
}
}
}
}

scope を利用して、いくつか処理を挟んでいますが、クエリの実行と model への代入は以下の部分です。
scope.scan() の実装を読むと、 interface{} で model を渡していることもあり、 reflection が多用されていました。

v1
func queryCallback(scope *Scope) {
...
// SQLDB() で gorm.SQLCommon を取得
// gorm.SQLCommon = *sql.DB であり、標準の Query を呼び出している
if rows, err := scope.SQLDB().Query(scope.SQL, scope.SQLVars...); scope.Err(err) == nil {
defer rows.Close()

columns, _ := rows.Columns()
for rows.Next() {
scope.db.RowsAffected++

elem := results
if isSlice {
elem = reflect.New(resultType).Elem()
}

// 第3 引数 の []*Fields を更新してレコードの値を代入
scope.scan(rows, columns, scope.New(elem.Addr().Interface()).Fields())
...

v1 での実装はここまでにして、 v2 の First() はどうなっているかを紐解いていきます。
実装を読む限り、 tx.callbacks.Query().Execute(tx) でクエリが実行されていそうなことがわかり、読みやすくなっています。

v2
// v2 First()
func (db *DB) First(dest interface{}, conds ...interface{}) (tx *DB) {
tx = db.Limit(1).Order(clause.OrderByColumn{
Column: clause.Column{Table: clause.CurrentTable, Name: clause.PrimaryKey},
})
if len(conds) > 0 {
if exprs := tx.Statement.BuildCondition(conds[0], conds[1:]...); len(exprs) > 0 {
tx.Statement.AddClause(clause.Where{Exprs: exprs})
}
}
tx.Statement.RaiseErrorOnNotFound = true
tx.Statement.Dest = dest
return tx.callbacks.Query().Execute(tx) // おそらくここ
}

まず、 tx.callbacks.Query() の実装を見ると、 mapに格納された query 向けの processor を取得しています。

v2
func (cs *callbacks) Query() *processor {
return cs.processors["query"]
}

v1 と同様に、processors が初期化されている実装を探してみると、 initializeCallbacks() が定義されており、 gorm.Open から呼ばれていました。 init() ではないので、ソースコードが追いやすく明示的に初期化できるようになっており、とても良い設計変更だと思いました。

v2
func initializeCallbacks(db *DB) *callbacks {
return &callbacks{
processors: map[string]*processor{
"create": {db: db},
"query": {db: db},
"update": {db: db},
"delete": {db: db},
"row": {db: db},
"raw": {db: db},
},
}
}

// gorm.Open で呼び出されている
func Open(dialector Dialector, opts ...Option) (db *DB, err error) {
...
db.callbacks = initializeCallbacks(db)

initializeCallbacks() の実装をよくみると、各 processor に gorm.DB を渡しているのみであることがわかります。要するに、 createquery に渡している processor に違いがない状態です。違いがない状態で、どのように発行するクエリを切り替えているのでしょうか。
(v1 では、processor ごとに異なる関数を渡すことで実装を切り替えてました。)

First() に戻ると、 tx.callbacks.Query().Execute(tx) が実行されているので、processor の Execute() メソッドが呼ばれていることがわかります。

[v2 processor.Execute()](https://github.com/go-gorm/gorm/blob/v1.21.11/callbacks.go#L75)
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)
}
}

var (
curTime = time.Now()
stmt = db.Statement
resetBuildClauses bool
)

if len(stmt.BuildClauses) == 0 {
stmt.BuildClauses = p.Clauses
resetBuildClauses = true
}

// assign model values
if stmt.Model == nil {
stmt.Model = stmt.Dest
} else if stmt.Dest == nil {
stmt.Dest = stmt.Model
}

// parse model values
if stmt.Model != nil {
if err := stmt.Parse(stmt.Model); err != nil && (!errors.Is(err, schema.ErrUnsupportedDataType) || (stmt.Table == "" && stmt.SQL.Len() == 0)) {
if errors.Is(err, schema.ErrUnsupportedDataType) && stmt.Table == "" {
db.AddError(fmt.Errorf("%w: Table not set, please set it like: db.Model(&user) or db.Table(\"users\")", err))
} else {
db.AddError(err)
}
}
}

// assign stmt.ReflectValue
if stmt.Dest != nil {
stmt.ReflectValue = reflect.ValueOf(stmt.Dest)
for stmt.ReflectValue.Kind() == reflect.Ptr {
if stmt.ReflectValue.IsNil() && stmt.ReflectValue.CanAddr() {
stmt.ReflectValue.Set(reflect.New(stmt.ReflectValue.Type().Elem()))
}

stmt.ReflectValue = stmt.ReflectValue.Elem()
}
if !stmt.ReflectValue.IsValid() {
db.AddError(ErrInvalidValue)
}
}

for _, f := range p.fns {
f(db)
}

db.Logger.Trace(stmt.Context, curTime, func() (string, int64) {
return db.Dialector.Explain(stmt.SQL.String(), stmt.Vars...), db.RowsAffected
}, db.Error)

if !stmt.DB.DryRun {
stmt.SQL.Reset()
stmt.Vars = nil
}

if resetBuildClauses {
stmt.BuildClauses = nil
}

return db
}

(Execute() を読んでみても、どこで SQL が実行されているかよくわからないですね..。)
よくわからなかったので、v2 の First() を呼ぶ簡易な実装をして、デバッグ実行してみたところ、 processor.fns にクエリを実行する関数がセットされていることがわかりました。

v2
func (p *processor) Execute(db *DB) *DB {
...
// クエリ発行はこの部分
for _, f := range p.fns {
f(db)
}

...


// セットされていた関数
// ./callbacks/query.go
func Query(db *gorm.DB) {
if db.Error == nil {
BuildQuerySQL(db)

if !db.DryRun && db.Error == nil {
rows, err := db.Statement.ConnPool.QueryContext(db.Statement.Context, db.Statement.SQL.String(), db.Statement.Vars...)
if err != nil {
db.AddError(err)
return
}
defer rows.Close()

gorm.Scan(rows, db, false)
}
}
}

gorm.Open で呼び出している initializeCallbacks() の実装を読む限りは、特に processor.fns がセットされていません。どこでセットしているか調べてみたところ、dialector の実装にて定義されていました。(つまり別パッケージで定義されていました。。)

go-gorm/sqlite/blob/master/sqlite.go#L40

v2
func (dialector Dialector) Initialize(db *gorm.DB) (err error) {
if dialector.DriverName == "" {
dialector.DriverName = DriverName
}

// ↓ こちら
callbacks.RegisterDefaultCallbacks(db, &callbacks.Config{
LastInsertIDReversed: true,
})
...

GORM にて定義されている、callbacks.RegisterDefaultCallbacks 関数内にて、 Query 関数を Register 関数を通して、 processor.fns へセットしています。

v2
func RegisterDefaultCallbacks(db *gorm.DB, config *Config) {
...
queryCallback := db.Callback().Query()
queryCallback.Register("gorm:query", Query) // Query 関数をセット

この実装を読み解くのに、一番苦労しました。 callback の登録である、 RegisterDefaultCallbacks 関数の呼び出しは、 dialector 側に委ねずに、 gorm.Opengorm.DB 生成時に実行すればよいのではと思いました。 dialector を新たに実装する際に抜け漏れる可能性もありますし、そもそもデフォルト値の設定なので別パッケージ側での呼び出しを期待するのは少々違和感があるなと感じました。(何よりも読みづらかったです。)

GORM のクエリ発行は、v1 と v2 どちらも callback を中心に設計されていました。 特定のクエリ操作(Create, Query, …) に対して複数の callback が定義され、callback 関数を順序を意識してセットしています。実際のクエリ呼び出しでは、セットされた callback 関数を呼び出すことだけをしています。これにより、 callback 関数を追加・削除することで柔軟にクエリ発行をアレンジすることができるようになっています。ここは v1 と v2 で変わっていない部分だと読み取れました。

v1
// callback_query.go
func init() {
DefaultCallback.Query().Register("gorm:query", queryCallback)
DefaultCallback.Query().Register("gorm:preload", preloadCallback)
DefaultCallback.Query().Register("gorm:after_query", afterQueryCallback)
}
v2
// callbacks/query.go
func RegisterDefaultCallbacks(db *gorm.DB, config *Config) {
...
queryCallback := db.Callback().Query()
queryCallback.Register("gorm:query", Query)
queryCallback.Register("gorm:preload", Preload)
queryCallback.Register("gorm:after_query", AfterQuery)
...

エッセンス

  • クエリ発行のような外部リソース呼び出しを行う関数は、呼び出しを実行していることがわかるような名前付けをすると良い
  • init() 関数はコードを追いかける範囲外での定義のためコードが読みづらい。代わりに initialize 関数を定義して明示的に呼び出すと良い
  • デフォルト値設定の呼び出しをパッケージ外にて期待するような実装はコードが読みづらい

Debug

v2 からは返却値の gorm.DB に対して、副作用なく debug モードが定義できるようになりました。v2 では元の gorm.DB を更新する実装でしたが、 v2 からは元の gorm.DB は更新せず新たに debug モードの gorm.DB が生成されていました。一部の処理だけ debug モードにしたいといった用途に対応できるようになっています。

v1
db.LogMode(true)
v2
db, err := gorm.Open(sqlite.Open("v2_test.db"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})

// or

// やっていることは上の実装のラッパー
db = db.Debug()

エッセンス

  • 副作用のない実装をすることで、影響範囲を狭めることができる

所感

GORM v1 と v2 のソースコード比較をしてみました。元々は、 v1 と v2 の機能比較も考えていたのですが、すでに記事もいくつかあり新たにまとめなくてもよいかと思い、ちょっと別の切り口にしてみました。インターフェイスを大きく崩すことなく、スクラッチで再実装したいケースの参考と慣れば良いなと思います。
v2 は読みづらい部分もありましたが、全体的にはきれいに再設計されていて、v1 と比較してより良くなっていると感じました。 データモデルの部分が若干わかっていないところがありまとめきれていないですが、モデル設計から再設計されている印象を受けました(DB, Statement, Scope 等)。
最後に、記載したエッセンスの一覧を載せておきます。

エッセンスまとめ

  • interface を利用して型付けを厳格にして実行時エラーを防御
  • 任意の項目は Functional options パターンで設定できるようにすると良い
  • config 値は、struct として定義して埋め込みで定義することで、設定値と struct で利用するフィールドを分離
  • 標準API から必要なメソッドのみを、抜き出して interface 定義することで、利用するメソッドを絞り込む
  • クエリ発行のような外部リソース呼び出しを行う関数は、呼び出しを実行していることがわかるような名前付けをする
  • init() 関数はコードを追いかける範囲外での定義のためコードが読みづらい。代わりに initialize 関数を定義して明示的に呼び出すと良い
  • デフォルト値設定の呼び出しをパッケージ外にて期待するような実装はコードが読みづらい
  • 副作用のない実装をすることで、影響範囲を狭めることができる

参考

次は筒井さんのSQLBoiler(とoapi-codegen)でつくるREST APIサーバです。