第56回情報科学若手の会で発表したスライドです 自作したGoのORMのその仕組みについて話します
GoでORMを自作してみる第56回情報科学若手の会株式会社DeNA 黄 英智
View Slide
黄 英智(Harrison Huang/ HarrisonEagle)● 東京都板橋区出身● 在日華僑三世● 早稲田大学基幹理工学部情報通信学科卒● 株式会社DeNA所属(23卒)○ 現在はSREとして自社機械学習基盤の開発と運用を担当● 〇〇自作はよくやる○ RustでWebフレームワーク自作したこともある○ 最近はJVM自作と仮想DOM自作にも挑んだりする● GitHub: HarrisonEagle● Twitter: @harris0n3ag1e
これから話す内容● 自作したGoのORM HawkORMの実装と内部の仕組みについて話します○ リポジトリ: https://github.com/HarrisonEagle/HawkORM● 特に下記の内容を中心に話ます○ ORMの中身はどうなっているのか○ 実行時にGoの構造体の情報を取得&変更する技術
こんな人にとっては面白いかも● 下記のようにDBのライブラリの内部実装に疑問を持っている人○ なんでSQL書かずにDB操作できるんだろ?○ 色んなクラス&構造体の情報はどうやってSQLに変換されているのだろ○ そもそもDBライブラリの実装はどんなんだろ?セキュリティは担保されているのか?○ ORMのようなDBライブラリの限界はなんなのか?
こんな人にとっては面白くないかも...● DB操作は生のSQLで行っている人○ ORM使うよりむしろSQLそのまま書いた方がやりやすいなど■ 「ORMばっか使ってると痛い目に遭うこともあるよ!」○ なんでORM使うより生のSQL書いた方が早いケースがあるのかはこのセッション通して理解できると思います
そもそもORMってなんぞ?● プログラミング言語で記述されたオブジェクトを、データベース上にある非互換なデータとマッピングする手法である。 オブジェクト関係マッピングとも呼ばれる。● 本来DB上のデータを取得、あるいは変更を加える場合は、SQL文を書いてDBエンジンに実行させる必要があるが、これらの処理と、オブジェクトへの変換はすべてプログラミング言語だけで完結できる● 代表ライブラリ: Ruby on railsのActiveRecord、TSのPrisma、GoのGORMなど
HawkORMができること● Goの構造体をSQLに変換し、DBエンジンで実行○ 内部はdatabase/sqlを使用○ 現在はMySQLだけサポート● WhereやLimitなどの条件を指定した SQLクエリの生成● 基本的なCRUD● Select Join, TransactionとPreloadはWIP
使用例// equal to: SELECT id, name, email, created_at, updated_at FROM users WHERE (id = "userId" OR name ="testname") ORDER BY id ASC LIMIT 1var users []UsertestDB.Select(&User{}).WhereOr(&User{ID: "userId", Name: "testname"}).First(&users)insertTest := []User{{ID: "userid1", Name: "user1", Email: "[email protected]"},{ID: "userid2", Name: "user2", Email: "[email protected]"},}// equal to: INSERT INTO users (id, name, email) VALUES ("userid1", "user1", "[email protected]"),("userid2", "user2", "[email protected]")res, err := testDB.Insert(&User{}).SetData(insertTest).Exec()
では実際どう動いているの?
そもそもORMはどう動いているのか?オブジェクト(Goの構造体)Whereなどの条件(HawkORMではこちらも構造体)構造体情報の読み込みオブジェクト(Goの構造体)SQLの生成SQLの実行&結果の返却SQL結果をオブジェクトにマッピングORMSELECTする場合に行われる
実行時の構造体情報の取得● 多くのプログラミング言語では、実行時に変数や関数の情報を取得することができるリフレクション機能が提供されている○ プログラムの実行時にプログラムの構造や構成要素(クラス、メソッド、関数など)についての情報を取得したり、プログラムの動作を動的に変更したりすることを リフレクションと言う● Goの場合、リフレクション機能は標準パッケージのreflectに含まれている。○ 実際Gormの場合は、標準の reflectパッケージで渡された構造体の解析と値のマッピングができる○ reflectパッケージで変数を解析する際は、変数を interface{}型にキャストする必要がある■ interface{}型はTypeScriptのany型と同様になんでも受け取れるので、こういった特殊な要件ではなければ使わない方が良い
リフレクションでとれる構造体の情報● 構造体の名前● 各フィールドの名前● 各フィールドにセットされた値● 各フィールドのタグ情報○ Gormと同様に、フィールドの TagにPrimaryKeyであることを明示したら PrimaryKeyとして処理できる● 各フィールドのポインタとアドレス:○ Selectした結果のオブジェクトマッピングに使用できる● 各フィールドの型情報○ 構造体なのか、それとも配列なのかの判定に使用
例: 構造体のフィールドをSQLで指定するカラムに変換 typeInfo := reflect.TypeOf(model) // 構造体内の各フィールドの型情報を取得valueInfo := reflect.ValueOf(model) // 構造体内の各フィールドの値を取得var columns []stringfor i := 0; i < typeInfo.NumField(); i++ {fieldType := typeInfo.Field(i).Type.Kind() // 構造体内の各フィールドの型を取得value := valueInfo.Field(i)if notZeroOnly && value.IsZero() {continue} // 型をチェックしてテーブルのフィールドなのか判断するif !(fieldType == reflect.Struct && typeInfo.Field(i).Type.String() != "time.Time") &&fieldType != reflect.Array { // 構造体の各フィールド名を SQLに入れるカラム情報として処理columns = append(columns, p.getColumnName(typeInfo.Field(i).Name))}}return columns
SQL構築に必要な情報の準備&実行SELECT id, nameFROM usersWHERE users.id IN (1, 2, 3)ORDER BY users.idASCLIMIT 10SQL句は、どの位置になんの情報を入れるか、どういう順番でSQL文を構築していくのか文法が決まっている選択するカラム(最初に来る)データソース(カラムの後に置かれる)選択条件(データソースの後に置かれる)どのソースを元に並べるのか昇順か降順件数の制限
SQL構築に必要な情報の準備&実行type MySQLSelectClause struct {dbpool *sql.DBProcessor *utils.ProcessorprimaryKey stringtableName stringcolumns []stringwhereConditions *WhereConditionorderBy []stringlimit int}SELECT id, name FROM users WHEREusers.id IN (1, 2, 3) LIMIT 10赤の部分が、リフレクションを通して取得できたオブジェクトの情報であるSQL句の構造をパターン化して、リフレクションから得られた情報を Clause構造体にまとめて管理する。Clause構造体からSQL句をビルド
SQL構築に必要な情報の準備&実行● DBによってSQLの構文が微妙に違うことがあるので、SQL操作を抽象化する● メソッドチェーン方式で、条件設定などの操作を行う度に、リフレクションを通してClause構造体に必要な情報がセットしてSQLを構築する○ Gormも似たような方式● 最終的に、ビルドしたSQL句を、DBクライアント(今回はdatabase/sql)に渡して実行するtype SelectClause interface {Limit(number int) SelectClauseOrderBy(orderBy []string) SelectClauseWhere(condition interface{}) SelectClauseWhereNot(condition interface{})SelectClauseWhereOr(condition interface{}) SelectClauseAll(target interface{}) errorFirst(target interface{}) errorLast(target interface{}) error}
SELECTした結果をオブジェクトにマッピングする● SQLの実行結果は行ごとのデータが帰ってくる● 返却すべきデータの構造は、最初の構造体情報を読みこむ段階で記憶しておく○ HawkORMではまだ対応していないが、 Go1.18から使用可能になった Genericsを使うとより安全な方法で処理に使用した型を記憶できそう● リフレクションを利用して、指定した構造体の型を元にマッピング先の構造体の初期化とポインタの取得を行う● SQL実行結果の各行をスキャンする際に、カラムとフィールドの順番を元に リフレクションで取得した構造体の各フィールドのポインタを渡すと、結果が構造体にマッピングできる
SELECTした結果をオブジェクトにマッピングするfor rows.Next() {var columns []interface{}result := reflect.New(typeinf).Elem().Addr().Interface() // マッピング用構造体の初期化s.assignFromArgs(result, &columns) // 各カラムを構造体のフィールドに紐付けるerr := rows.Scan(columns...) // SQL実行結果を各カラムに対応するフィールドにマッピングif value.Kind() == reflect.Slice { // 構造体と配列の判定value.Set(reflect.Append(value, reflect.Indirect(reflect.ValueOf(result))))} else if value.Kind() == reflect.Struct {// reflect.Indirectでスキャンした結果を構造体にセットvalue.Set(reflect.Indirect(reflect.ValueOf(result)))}if err != nil {return err}}ここまで来たらORMとして一通り成り立つようになる
HawkORMの今後● Selectに関してはJoinに対応させる● 外部キーなどで親子関係が結ばれているオブジェクトについては Preloadに対応させる● Genericsに対応してより型安全な方法で処理するように改良● トランザクションの対応
実際作ってみてどうだったの?
考察1: ORMには限界がある● ORMには限界がある○ 例えばSELECT…FROMに設定したデータソースは、テーブルではなく他のサブクエリに設定したい場合は、 SQLのパターン化が複雑化し、 ORMだけで表現するのは難しくなる■ ORM使うより生のSQL書いた方が早いケース○ 型安全とデータオブジェクトの柔軟性を両立させる設計はプログラミング言語によって工夫が必要■ 例えば、Joinでオブジェクトに含める内容が複数テーブルを跨いだ場合、型安全な設計でやろうとすると、プログラミング言語の特性によっては複雑化して開発者体験が悪くなりやすい■ ActiveRecordやPrismaのようなDXをGoで完全再現するのは難しい
考察2: Reflectionは意外と危ない● interface{}型とReflectionはちゃんと型チェックしたり、動的に構造体を変更する時に設計上のミスを防ぐ仕組みが乏しい○ 実際結構使われている ORMライブラリでも、特殊なデータ構造を Reflectionで変更する際にnilポインタに引っ掛かって落ちるトラブルが起こることがある○ Genericsを導入すればある程度型安全になるが、万能ではない● Reflectionは実行時にメモリ上の変数を触ることなので、そもそも性能的によろしく無かったりする○ 特に構造体の情報の解析が計算量が多い実装になっている場合● より型安全な方法で実現したいのであれば、 Code Generationによる型を自動生成を活用してReflectionを最小限に抑えるのが良さそう○ Metaのentはこの方式○ gormもCode Generationで型安全にしようとしている試みをしている
考察3: 〇〇自作は結構学びになる● 車輪の再発明による成果は実用できるかどうかは置いといて、低レイヤーとライブラリ内部の仕組みを知ることは割と力になる○ 現代のソフトウェア開発はライブラリとフレームワークを頼ることが多いが、その設計思想と仕組みを理解すれば、より柔軟性の高い設計に挑んだり、ライブラリの不具合に対し適切な解決策を出しやすくなる
まとめ● オブジェクトマッピングと、構造体の情報取得は、リフレクションを使えば実現できる● SQL文をパターン化してリフレクションで取得した情報を元にビルドして実行することにより、ORMを実装できる● リフレクションにはリスクがあり、使うのであれば最低限に抑えつつ、 ORM的なものを作る際はGenericsや型の自動生成なども活用した方が良い● ORMには限界がある● 〇〇自作はめっちゃ楽しいし、役に立つ知見も頭の中に入ってくる
参考文献● The Laws of Reflection: https://go.dev/blog/laws-of-reflection● reflect: https://pkg.go.dev/reflect● go-gorm/gorm: https://github.com/go-gorm/gorm● go-gorm/gen: https://github.com/go-gorm/gen● ent/ent: https://github.com/ent/ent
ご清聴ありがとうございました!
Q&A