LoginSignup
23
12

More than 3 years have passed since last update.

Gormとの破局、そしてFacebook/entとの出会い

Last updated at Posted at 2020-12-01

golangとの出会い

御岳山のミツバツツジが紫の花をつけはじめ高山でも徐々にコケモモやアヤメなどのいろとりどりの花が見て取れるようになった時期、RESTよりパフォーマンスの良いgRPCをチャレンジしようという流れでgRPC使うならサーバサイドは折角だしgolang使おうとなった辺りがgolangとの出会い。
散々他所でも言われていますがgolangくんは付き合ってみるとシンプルを売りにしている反面結構頑固な所が多く割と融通の効かない子です。それ故にアプリケーション全体のアーキテクチャ設計で頭使う必要があり無茶な実装はlinterで弾きやすくレビュアーにとっては優しくたまに不満はあるけれど全体としてみれば嫌いじゃないかなという気持ちでここまで付き合いが続いています。
golangくん個人開発ではそこまで旨味なさそうだけど複数人の開発では使えば使うほど味が出てくるスルメみたいな子。golangはいいぞ。
なお本記事は本質部分ではない部分のerrorチェック処理等は全部カットしてあります。ご了承ください。

Gormとの出会い

そんな頑固一徹で素材の味を活かすのが持ち味なgolangくんなのですが、流石にデータベースを扱うときには実装者にconnectionをあんまり意識させたくないし、データベースから取得したレコードをstructにMappingするために結果セットをfor statementで回すのは面倒くさい上にあまりシンプルな実装にならないだろうなと思い、Active RecordのようなORMを採用しようと最初から決めていました。
当初は自分もそこまでgolang界隈に詳しくなかったのでORM libraryの選定の際ひとまずGithubのスターの数多いし開発止まってなさそうなのでGormを使ってみるかみたいな感じでGormを選んだのがGormくんとの出会いです。

Gormと過ごした日々

Gormを導入した時、標準のsqlパッケージのようにQueryを投げてrows.Next()で回すような手続き型ではなく

type Group struct {
    ID    int
    Name  string
    Users []User
}

type User struct {
    ID      int
    GroupID int
    Name    string
}

func main() {
    db := connection()
    defer db.Close()

    query := `
SELECT users.id, users.name
FROM groups
INNER JOIN users ON groups.id = users.group_id
WHERE groups.name = ?
`
    groupName := "LiGHTs"
    rows, _ := db.Query(query, groupName)

    var users []*User
    for rows.Next() {
        var user User
        if err := rows.Scan(&user.ID, &user.Name); err != nil {
            log.Fatal(err)
        }
        users = append(users, &user)
    }
    fmt.Println(users)
}

func connection() *sql.DB {
    // 省略
}

メソッドチェーンで記述できQueryの構築からDBへの発行structへのMappingも行ってくれるため「おっ結構便利なやつだな」というのが最初の印象。

type Group struct {
    ID    int
    Name  string
    Users []*User
}

type User struct {
    ID      int
    GroupID int
    Name    string
}

func main() {
    db := connection()

    var group Group
    if err := db.Preload("Users").
        Where(Group{Name: "LiGHTs"}).
        Find(&group).Error; err != nil {
        fmt.Println(err.Error())
        return
    }
    fmt.Println(group)
}

func connection() *gorm.DB {
    // 省略
}

その後は記事を更新しつつGormと仲良くなるべく日々を過ごしていました。

gormと仲良くなりたい(1) - gorm の Where/Update で struct を使いたい
gormと仲良くなりたい(2) - datetimeでマイクロ秒(6桁)まで格納/取得する

Gormとの破局

GormのDELETEで誤爆しないために
のような記事を書いていた頃、実装の際に壁にぶち当たりドキュメントを調べてGorm本体のソースを追ってるうちに「あれ?もしかしてGormのイケてないのでは?」と徐々に思うようになってきてしまいました。
そして、イケてないと思い始めると思い当たる節がいくつも出てきてパッと思いつく限り挙げていくと

主に コーディング時に気付かずbug埋め込んでしまうタイプの罠が多い事ドキュメントが割と不親切で読んでも解決できずに結局gorm本体のコードを読む事が多い所が物凄い不満が溜まっていき敢え無く破局へと追い込まれていきました。

破局の根本の原因としては

  • Gormは「コードの記述量を減らす事」を最優先として設計されており「コードに書かれていない事も"意図せず"ある程度いい感じに動いてしまう」ようになっていること
  • Select結果のMapping先だけでなくWhere条件やUpdate対象までinterfaceで引数を受け取りreflectionで頑張って解析するという作りにしている為に、Gormの内部でstruct内のfieldのゼロ値がゼロ値なのかnilなのか判断できないという問題にぶち当たり、結果的にGormに値がゼロ値のstructを渡した場合初めて分かるタイプのbugが量産されてしまったこと
  • TABLE JOINやPreloadの仕様が独特で内部でreflectionを行っている都合「field名(=物理column名)」を意識しなくてはいけないこと。あとそれに伴いPreload時にはstringでfield名を定義する必要があること

ここらへんなのかなと思っています。
unit testでの値のケースでたまに漏れているとdeploy後にbugに気づく事が度々あり実装者が常にそこを意識して実装するのは結構ストレスが高かったです。

Facebook/entとの出会い

Gormの反省を踏まえてgenericsがなくて*4型にうるさいgolangとしてのlibraryの在り方としてはinteface型で引数を受け取りreflectionを多用して頑張る方向ではなく、codeで定義書いてその定義からcodeをgenerateする方向が向いているのではないかと思いはじめた矢先Facebook/Entに出会いました。

Gormからの乗り換え先を探す際の最低限の基準として考えていた

  • schema定義をcodeで書けること
  • schema定義からQuery生成関連のcodeをgenerate出来ること
  • schema定義が正しいかgenerate時にチェックしてくれること
  • 静的型付けされたSetter/Getterが用意されていること
  • generateされるcodeがある程度カスタマイズ出来ること
  • Datadogを使用してOpenTracingの出力が確認できるようGo Datadog Traceに対応しているか

の条件を満たし、かつまあ企業が運用しているならGormがやらかしていたデータ全消し仕様なんて妙なトラップもないだろうという打算もありつつ新しく付き合い始めました。

さっきまでの実装をFacebook/entで実装すると

schema定義ここから ---->

schema/groups.go
// Groups holds the schema definition for the Groups entity.
type Groups struct {
    ent.Schema
}

// Fields of the Groups.
func (Groups) Fields() []ent.Field {
    return []ent.Field{
        field.String("name"),
        field.String("leader").Optional(),
    }
}

// Edges of the Groups.
func (Groups) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("users", Users.Type).
            Ref("group"),
    }
}
schema/users.go
// Users holds the schema definition for the Users entity.
type Users struct {
    ent.Schema
}

// Fields of the Users.
func (Users) Fields() []ent.Field {
    return []ent.Field{
        field.String("name"),
    }
}

// Edges of the Users.
func (Users) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("group", Groups.Type).
            StorageKey(edge.Column("group_id")).
            Unique().
            Required(),
    }
}

<---- schema定義ここまで
schema定義はテーブルごとに必要になります。

以下が実装の本体。

main.go
func main() {
    ctx := context.Background()
    client := connection()

    group, err := client.Groups.Query().
        WithUsers().
        Where(groups.Name("LiGHTs")).
        Only(ctx)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(group.Edges.Users)
}

func connection() *ent.Client {
    // 省略
}

コード生成するのでコード量自体は10倍くらいに増えますが、schema定義さえしっかりしていれば
必要そうなAPIが大体実装されているので、例えばGormで散々悩まされたゼロ値とnilの区別の問題もschema側でfieldをOptionalで定義すれば

schema/groups.go
// Fields of the Groups.
func (Groups) Fields() []ent.Field {
    return []ent.Field{
        field.String("name"),
        field.String("leader").Optional(),
    }
}
main.go
    group, err = client.Groups.Query().
        WithUsers().
        Where(groups.LeaderIsNil()).
        Only(ctx)

のようにIS NULL検索なども問題なく行えるようになります。
※念のためGormでIS NULL検索が出来ないわけではないです。あくまでWhereにstructを渡した場合の話。

Facebook/entを使ってみての所管

Facebook/entを使い始めた早1か月ちょい、まだ使い始めたばかりでいくらか検証の余地は残ってはいるのですがFacebook/entの特徴と実装してみたところの感想としては以下の通りです。

  • まだv1はリリースされていない絶賛開発中
  • Go templatesを利用したQuery生成APIのgenerateがメイン
  • Go templatesを記載する事により一部生成codeの拡張が可能
  • ドキュメントが比較的丁寧(だが、Edgeの説明はあんまり親切じゃない)
  • 実装時の制約となるvalidation checkの仕組みが整っている
  • generate時のerrorメッセージが分かりやすい ←割とありがたい
  • schemaからgenerateされたQuery生成APIは静的型付けが行われる
    • ただしselectで取得するcolumn絞ったりするとreflectionが使用されたりはする
  • TABLE JOIN statementは書けない
    • Graph Traversalという独特の思想(結合対象のテーブルに対して対象のid一覧をin句に指定したselectを自動で行う)
  • 複合PKに対応出来ないわけではないがあまり考慮はされていない
  • PKの名前がidじゃない場合schemaの記述にもひと手間工夫が必要
  • PKの名前がidじゃない場合のO2O周りの結合の挙動が怪しい
  • Upsertには現状対応していない 協議はされている
  • SELECT FOR UPDATEのようなqueryが書けない 協議はされている

物理schemaがAPI毎の概念schemaに合わせて設計されているならば恐らく問題なく使用できる子であるとは思いますが、頑張ってJOINしないとデータが取れないテーブル設計やパフォーマンス重視のマジカルなテーブル設計の場合は厳しそうです。ここら辺はRawで生SQLが書ける分まだGormの方が柔軟性がありました。

幸いにも今回は元々APIに合わせて物理schemaが設計されており、Gormからの乗り換えで気になったのはGorm側の仕様でテーブル名が複数形になってる事とO2Oでの結合くらいでした。
後者はPKの名前と型がid intではない場合、定義は出来ますがO2OのEdgeを定義するとGraph Traversalでrecordが取得できなかったり、そこだけはちょっと予想外な動作をしていたためもしかしたらバグなのかもしれません。Facebook/entの実装はgoではなくgo templateなので読むのがちと辛いですがバグの内容が分かったらPRを出そうかと。
どちらにせよ現在開発中なのでこの問題は時間経過で解決されるとは思います。

Graph Traversal周り分かりにくい所はありますが現状問題なく使えてはいます。
とはいえUpsertは実装でどうにかなりますがSELECT FOR UPDATE辺りは早めに欲しいかな。

クリスマスが終わったらまた破局しないようにお祈りしつつ 今回はここまで。

補足

*1 全件DELETE

ちなみにgorm v1での動作です。v2ではerrorになるので突然消されることはなくなりました。

*2 ゼロ値

string = "" int = 0 bool = false 等の要するに初期値の事。

GormではUpdateの際に更新値としてstrutcを渡せるが、struct定義がゼロ値の場合は更新されない。公式ドキュメントにもサラッと記載がある。

type User struct {
    Name   string
    Age    int
    Active bool
}

// Update attributes with `struct`, will only update non-zero fields
db.Model(&user).Updates(User{Name: "", Age: 0, Active: false})

上記の値でUpdateを行った場合は何も更新されない

*3 双方に互換性がない

例えばgorm v1ではPrimaryKeyのstruct tagの指定がprimary_keyだがgorm v2ではprimaryKeyである
Gormは2つのバージョンがありドキュメントは分かれているという前提条件を知らずにドキュメントを見ていたりすると想定通りに動かなくてハマる。何故ドキュメントを2つに分けたし。

*4 genericsがない

golang 次期メジャーバージョン辺りでは正式に実装されるはずなので大幅に便利になりそう。
The Next Step for Generics

23
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
23
12