- 多賀聡一朗
- 約 7,600 文字
- 2,500 View
[GORM v2 doc](https://gorm.io/) より
概要
TIG DXユニット 多賀です。GoのORマッパー連載の4日目の記事です。
GORM の v1 と v2 の実装を比較して、何が変わっているのかを調査してみました。
v1 -> v2 への移行や、詳細な変更点については別の記事を見ていただいたほうが良いかと思います。
- GORM 2.0 Release Note | GORM - The fantastic ORM library for Golang, aims to be developer friendly.
- GORM v2 触ってみた Major Features 編 - Techtouch Developers Blog
- GORM v2 触ってみた Breaking Changes 編 - Techtouch Developers Blog
当記事では、ソースコードの差分を眺めてみてなにか学びがないかを調べてみた記事になっています。
完全にスクラッチで書き直しているとのことで、エッセンスが吸収できると良いなと思っています。
調査バージョン
| バージョン | リポジトリ | タグ |
|---|---|---|
| v1 | jinzhu/gorm | v1.9.16 |
| v2 | go-gorm/gorm | v1.21.11 |
ディレクトリ構造
まずはディレクトリ構造の差分を比較してみます。
❯ tree -L 1 --dirsfirst |
❯ tree -L 1 --dirsfirst |
v1 ではパッケージが切られていない設計に対して、v2 ではパッケージを分けた設計に変更されています。callbacks_xxx.go が callbacks パッケージにまとめられていそうですが、その他の実装がどのように変更されたかはディレクトリ構造を見るだけではわからないですね。
gorm.Open
GORM 利用時は、 gorm.Open 関数を利用して database/sql パッケージの sql.DB をラップした GORM 向けの gorm.DB オブジェクトを取得します。取得のインターフェース含めて何が変わっているのでしょうか?
API を見てみると、インターフェース自体がまず変わっていて、第一引数の dialect を文字列ではなく gorm.Dialector で受けるようになっています。なので、 "postgres" や "mysql" の文字列指定ができなくなっていますね。
func Open(dialect string, args ...interface{}) (db *DB, err error) |
func Open(dialector Dialector, opts ...Option) (db *DB, err error) |
gorm.Dialector を見てみると、 interface が定義されています。
// https://pkg.go.dev/gorm.io/gorm#Dialector |
Dialector interface の実装ですが、ドキュメントを見てみると別リポジトリでされていることがわかりました。各 DB driver 毎に dialector が実装されています。
- https://github.com/go-gorm/postgres
- https://github.com/go-gorm/mysql
- https://github.com/go-gorm/sqlite
- https://github.com/go-gorm/sqlserver
- https://github.com/go-gorm/clickhouse
- https://github.com/go-gorm/bigquery
(BigQuery 向けの dialector が実装されているのが意外でした。)
使い方としては、 各パッケージにて Open 関数が定義されているようでそちらを呼び出して、各 DB ごとの dialector を取得します。 (※ module 名がリポジトリ URL と異なるので注意が必要です。)
// sqlite |
v1 と異なり、利用者側で driver を blank import しなくて良くなりました。 GORM が提供する dialector の実装内で既に定義されているためです。それぞれの dialector の実装を見たところ、 Postgres の driver が jackc/pgx になっていた点が意外でした ( lib/pq をよく使っていました )。
driver を変更したい場合は、 gorm.Dialector interface を実装する必要があり、少し選択の自由度が下がってますね。
余談
jackc/pgx は database/sql と 独自のインターフェースのどちらも対応している点が lib/pq と異なり、独自のインターフェースではより Postgres の特徴を利用できる模様です。
- GitHub - jackc/pgx: PostgreSQL driver and toolkit for Go
- [QUESTION] Comparison with lib/pq · Issue #73 · jackc/pgx · GitHub
第2引数以降の指定も変更されています。 Functional options パターンが使われるようになっていますね。
func Open(dialect string, args ...interface{}) (db *DB, err error) |
func Open(dialector Dialector, opts ...Option) (db *DB, err error) |
Option は interface になっています。 Apply(*Config) error が適用される option です。
type Option interface { |
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) { |
[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) { |
第一に、Open の返却値である DB struct のフィールド構成が大きく変更されています。
type DB struct { |
type DB struct { |
v2 では 設定値が Config struct の埋め込みで表現されていて、設定値のフィールド項目がわかりやすくなっています。また先程の、 Option interface を Config struct が満たしているため、設定値をまとめて渡すことができるようになっています。
db, err := gorm.Open(dialector, &gorm.Config{}) |
v1, v2 とも sql.DB をラップしているのですが、 struct をぱっと見ただけではどこに持っているのかわからないです。実態はこちらです。
type DB struct { |
type Config struct { |
どちらも、 sql.DB を満たす interface が定義されているのですが、interface 定義も少し改良が加えられています。 v2 では Context 対応のメソッドを利用するように変更されていて、 Context に正式に対応していることがわかります。 database/sql のインターフェースは以下の 4メソッドだけしか利用されていないのも少々驚きました。(正確には、Transaction 系のメソッドも利用されています。 別で TxBeginner TxCommitter interface が GORM 内で定義されており、型変換により dabase/sql の各 Transaction 系のメソッドを呼び出していました。)
type SQLCommon interface { |
type ConnPool interface { |
ちなみに、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) { |
PreparedStmtDB struct にて prepare された Stmt を管理して、クエリ実行時に prepare されているかキャッシュ ( Stmts フィールド) を検索して利用しています。
gorm/prepare_stmt.go at v1.21.11 · go-gorm/gorm
type PreparedStmtDB struct { |
クエリ発行
クエリ発行の比較として、先頭一行を SELECT する First() 関数の実装を読んでみます。
func (s *DB) First(out interface{}, where ...interface{}) *DB { |
v2: gorm/finisher_api.go#First
func (db *DB) First(dest interface{}, conds ...interface{}) (tx *DB) { |
API のインターフェースは変わっていないですが、引数の命名が変更されています。
// sql の結果の出力先は destination と名付けられている模様です |
実装を読むと、v1 は Scope struct を利用して SQL を実行していたのに対して、v2 では特に Scope struct は利用せず gorm.DB を tx 変数へ格納の上で、そのまま利用しています。
そもそも v1 の Scope はどういった利用用途であったかを調べてみると、 Scope のコメントにあるように実行する特定のクエリ操作の状態のみを含むオブジェクト、を指している模様です。 First() で呼び出している db.NewScope() メソッドを見ると、 gorm.DB を clone して Scope へ渡しておりクエリ発行毎に Scope を生成していることがわかります。
// Scope contain current operation's information when you perform any operation on the database |
v2 では、 First() 内で直接呼び出してはないですが、 First() で呼び出している Limit() や Order() 内の gorm.DB.getInstance() メソッドで同様の処理をしています。
v2 では gorm.DB をそのままコピーして利用しつつ、Statement をクエリ発行毎に 発行 or clone しています。
func (db *DB) Limit(limit int) (tx *DB) { |
Statement の定義は以下です。 scopes はフィールドで持つ構造になっています。
type Statement struct { |
Scope を生成しているところから、Statement へ変更されていますが、実態としては大きな変更は入っていない印象でした。
(データモデルやインターフェースは変わっていますが、やっていることはあまり変わっていないため。)
続いて実際のクエリ発行と、model への適用はどこでやっているのでしょうか。
v1, v2 ともにレコード取得は以下のメソッド呼び出しで完結しています。
// v1 と v2 どちらも同様 |
v1 から見てみると、First メソッド内のどこかしらでクエリ発行が行われているはずですが、実装を見ても正直良くわからないです。
// v1 First() |
おそらく、 callCallbacks にて実行されていると推測しましたが、実装をみると引数で渡された関数を呼び出しているのみでした。
func (scope *Scope) callCallbacks(funcs []*func(s *Scope)) *Scope { |
callCallbacks の引数である、s.parent.callbacks.queries にクエリを実行する関数が渡っていそうなので、どこで定義しているか調べてみると、 gorm.Open にて DefaultCallback を渡していました。
func Open(dialect string, args ...interface{}) (db *DB, err error) { |
さらに、 DefaultCallback をみると、 Callback struct が格納されているだけで、 queries フィールドが初期化されていません。
// callback.go |
どこかで初期化しているところはないか、調べてみると init() が利用されてました。 init() が利用されていると、ソースコードが追いづらくて、読みづらかったです。
// callback_query.go |
クエリ発行の実態は、 Register() で渡されている queryCallback 関数でした。
[v1 gorm.queryCallback](https://github.com/jinzhu/gorm/blob/v1.9.16/callback_query.go#L17)
func queryCallback(scope *Scope) { |
scope を利用して、いくつか処理を挟んでいますが、クエリの実行と model への代入は以下の部分です。
scope.scan() の実装を読むと、 interface{} で model を渡していることもあり、 reflection が多用されていました。
func queryCallback(scope *Scope) { |
v1 での実装はここまでにして、 v2 の First() はどうなっているかを紐解いていきます。
実装を読む限り、 tx.callbacks.Query().Execute(tx) でクエリが実行されていそうなことがわかり、読みやすくなっています。
// v2 First() |
まず、 tx.callbacks.Query() の実装を見ると、 mapに格納された query 向けの processor を取得しています。
func (cs *callbacks) Query() *processor { |
v1 と同様に、processors が初期化されている実装を探してみると、 initializeCallbacks() が定義されており、 gorm.Open から呼ばれていました。 init() ではないので、ソースコードが追いやすく明示的に初期化できるようになっており、とても良い設計変更だと思いました。
func initializeCallbacks(db *DB) *callbacks { |
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)
func (p *processor) Execute(db *DB) *DB { |
(Execute() を読んでみても、どこで SQL が実行されているかよくわからないですね..。)
よくわからなかったので、v2 の First() を呼ぶ簡易な実装をして、デバッグ実行してみたところ、 processor.fns にクエリを実行する関数がセットされていることがわかりました。
func (p *processor) Execute(db *DB) *DB { |
gorm.Open で呼び出している initializeCallbacks() の実装を読む限りは、特に processor.fns がセットされていません。どこでセットしているか調べてみたところ、dialector の実装にて定義されていました。(つまり別パッケージで定義されていました。。)
go-gorm/sqlite/blob/master/sqlite.go#L40
func (dialector Dialector) Initialize(db *gorm.DB) (err error) { |
GORM にて定義されている、callbacks.RegisterDefaultCallbacks 関数内にて、 Query 関数を Register 関数を通して、 processor.fns へセットしています。
func RegisterDefaultCallbacks(db *gorm.DB, config *Config) { |
この実装を読み解くのに、一番苦労しました。 callback の登録である、 RegisterDefaultCallbacks 関数の呼び出しは、 dialector 側に委ねずに、 gorm.Open の gorm.DB 生成時に実行すればよいのではと思いました。 dialector を新たに実装する際に抜け漏れる可能性もありますし、そもそもデフォルト値の設定なので別パッケージ側での呼び出しを期待するのは少々違和感があるなと感じました。(何よりも読みづらかったです。)
GORM のクエリ発行は、v1 と v2 どちらも callback を中心に設計されていました。 特定のクエリ操作(Create, Query, …) に対して複数の callback が定義され、callback 関数を順序を意識してセットしています。実際のクエリ呼び出しでは、セットされた callback 関数を呼び出すことだけをしています。これにより、 callback 関数を追加・削除することで柔軟にクエリ発行をアレンジできるようになっています。ここは v1 と v2 で変わっていない部分だと読み取れました。
// callback_query.go |
// callbacks/query.go |
エッセンス
- クエリ発行のような外部リソースを呼び出す関数は、呼び出していることがわかるような名前付けをすると良い
- init() 関数はコードを追いかける範囲外での定義のためコードが読みづらい。代わりに initialize 関数を定義して明示的に呼び出すと良い
- デフォルト値設定の呼び出しをパッケージ外にて期待するような実装はコードが読みづらい
Debug
v2 からは返却値の gorm.DB に対して、副作用なく debug モードが定義できるようになりました。v2 では元の gorm.DB を更新する実装でしたが、 v2 からは元の gorm.DB は更新せず新たに debug モードの gorm.DB が生成されていました。一部の処理だけ debug モードにしたいといった用途に対応できるようになっています。
db.LogMode(true) |
db, err := gorm.Open(sqlite.Open("v2_test.db"), &gorm.Config{ |
エッセンス
- 副作用のない実装をすることで、影響範囲を狭めることができる
所感
GORM v1 と v2 のソースコード比較をしてみました。元々は、 v1 と v2 の機能比較も考えていたのですが、すでに記事もいくつかあり新たにまとめなくてもよいかと思い、ちょっと別の切り口にしてみました。インターフェースを大きく崩すことなく、スクラッチで再実装したいケースの参考と慣れば良いなと思います。
v2 は読みづらい部分もありましたが、全体的にはきれいに再設計されていて、v1 と比較してより良くなっていると感じました。 データモデルの部分が若干わかっていないところがありまとめきれていないですが、モデル設計から再設計されている印象を受けました(DB, Statement, Scope 等)。
最後に、記載したエッセンスの一覧を載せておきます。
エッセンスまとめ
- interface を利用して型付けを厳格にして実行時エラーを防御
- 任意の項目は Functional options パターンで設定できるようにすると良い
- config 値は、struct として定義して埋め込みで定義することで、設定値と struct で利用するフィールドを分離
- 標準API から必要なメソッドのみを、抜き出して interface 定義することで、利用するメソッドを絞り込む
- クエリ発行のような外部リソースを呼び出す関数は、呼び出していることがわかるような名前付けをする
- init() 関数はコードを追いかける範囲外での定義のためコードが読みづらい。代わりに initialize 関数を定義して明示的に呼び出すと良い
- デフォルト値設定の呼び出しをパッケージ外にて期待するような実装はコードが読みづらい
- 副作用のない実装をすることで、影響範囲を狭めることができる
参考
- GORM Guides (v1) | GORM - The fantastic ORM library for Golang, aims to be developer friendly.
- jinzhu/gorm at v1.9.16
- GORM Guides (v2) | GORM - The fantastic ORM library for Golang, aims to be developer friendly.
- go-gorm/gorm at v1.21.11
- Goのデバッグ環境 on VSCode | フューチャー技術ブログ
次は筒井さんのSQLBoiler(とoapi-codegen)でつくるREST APIサーバです。