[GORM v2 doc](https://gorm.io/) より
概要 TIG DXユニット 多賀です。GoのORマッパー連載 の4日目の記事です。
GORM の v1 と v2 の実装を比較して、何が変わっているのかを調査してみました。 v1 -> v2 への移行や、詳細な変更点については別の記事を見ていただいたほうが良いかと思います。
当記事では、ソースコードの差分を眺めてみてなにか学びがないかを調べてみた記事になっています。 完全にスクラッチで書き直しているとのことで、エッセンスが吸収できると良いなと思っています。
調査バージョン
ディレクトリ構造 まずはディレクトリ構造の差分を比較してみます。
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.go が callbacks パッケージにまとめられていそうですが、その他の実装がどのように変更されたかはディレクトリ構造を見るだけではわからないですね。
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 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 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/pgx は database/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 } 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 db SQLCommon blockGlobalUpdate bool logMode logModeValue logger logger search *search values sync.Map parent *DB callbacks *Callback dialect Dialect singularTable bool 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 ...
v2 type Config struct { ConnPool ConnPool ...
どちらも、 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() の呼び出し時の opts に gorm.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 = 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 PreparedSQL []string Mux *sync.RWMutex ConnPool } type Stmt struct { *sql.Stmt Transaction bool } 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) 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 のインターフェースは変わっていないですが、引数の命名が変更されています。
out -> dest where -> conds
実装を読むと、v1 は Scope struct を利用して SQL を実行していたのに対して、v2 では特に Scope struct は利用せず gorm.DB を tx 変数へ格納の上で、そのまま利用しています。 そもそも v1 の Scope はどういった利用用途であったかを調べてみると、 Scope のコメントにあるように実行する特定のクエリ操作の状態のみを含むオブジェクト、を指している模様です。 First() で呼び出している db.NewScope() メソッドを見ると、 gorm.DB を clone して Scope へ渡しておりクエリ発行毎に Scope を生成していることがわかります。
v1 type Scope struct {... 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 { tx.Statement = &Statement{ DB: tx, ConnPool: db.Statement.ConnPool, Context: db.Statement.Context, Clauses: map [string ]clause.Clause{}, Vars: make ([]interface {}, 0 , 8 ), } } else { 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 Omits []string 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 から見てみると、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 }
おそらく、 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 var DefaultCallback = &Callback{logger: nopLogger{}}
どこかで初期化しているところはないか、調べてみると init() が利用されてました。 init() が利用されていると、ソースコードが追いづらくて、読みづらかったです。
v1 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 } 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) {... 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()) ...
v1 での実装はここまでにして、 v2 の First() はどうなっているかを紐解いていきます。 実装を読む限り、 tx.callbacks.Query().Execute(tx) でクエリが実行されていそうなことがわかり、読みやすくなっています。
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) }
まず、 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}, }, } } func Open (dialector Dialector, opts ...Option) (db *DB, err error ) {... db.callbacks = initializeCallbacks(db)
initializeCallbacks() の実装をよくみると、各 processor に gorm.DB を渡しているのみであることがわかります。要するに、 create と query に渡している 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 { 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 } if stmt.Model == nil { stmt.Model = stmt.Dest } else if stmt.Dest == nil { stmt.Dest = stmt.Model } 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) } } } 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) } ... 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)
この実装を読み解くのに、一番苦労しました。 callback の登録である、 RegisterDefaultCallbacks 関数の呼び出しは、 dialector 側に委ねずに、 gorm.Open の gorm.DB 生成時に実行すればよいのではと思いました。 dialector を新たに実装する際に抜け漏れる可能性もありますし、そもそもデフォルト値の設定なので別パッケージ側での呼び出しを期待するのは少々違和感があるなと感じました。(何よりも読みづらかったです。)
GORM のクエリ発行は、v1 と v2 どちらも callback を中心に設計されていました。 特定のクエリ操作(Create, Query, …) に対して複数の callback が定義され、callback 関数を順序を意識してセットしています。実際のクエリ呼び出しでは、セットされた callback 関数を呼び出すことだけをしています。これにより、 callback 関数を追加・削除することで柔軟にクエリ発行をアレンジできるようになっています。ここは v1 と v2 で変わっていない部分だと読み取れました。
v1 func init () { DefaultCallback.Query().Register("gorm:query" , queryCallback) DefaultCallback.Query().Register("gorm:preload" , preloadCallback) DefaultCallback.Query().Register("gorm:after_query" , afterQueryCallback) }
v2 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
v2 db, err := gorm.Open(sqlite.Open("v2_test.db" ), &gorm.Config{ Logger: logger.Default.LogMode(logger.Info), }) 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サーバ です。