Go
golang
GORM

Go の ORM Mapper - Gorm のノウハウについて

はじめに

この記事は SmartDrive Advent Calendar 2017 の 11 日目の記事です。

こんにちは、SmartDrive でバックエンドエンジニアをしている @yagihiro です。
弊社では、サーバーサイドの開発言語として Go 言語を使っています。
この記事では、私達の運営しているサービスで実際に使っている OR Mapper ライブラリ Gorm についてのノウハウをまとめてみました。ご参考になれば幸いです。

マイグレーション

Gorm には Auto Migration 機能 が組み込まれていますが、弊社 ではこの機能は利用しておらず、替わりに migu をカスタマイズして利用しています。

migu は ridgepole を参考に作られた Go 言語製のツールで、冪等性をもってマイグレーションを実行できるようにするツールになります。詳細はリンク先をご確認ください。

弊社で migu をカスタマイズした当時は migu 単体でインデックスを貼ることができない等の制約があったため独自に拡張を行いました。
最近になって 本家 migu が v0.2.0 に更新され、 インデックスをサポートするようになった様子なので今から利用したいという方は本家を使われるのが良いかと思います。

弊社でカスタマイズした migu のスキーマ定義は以下のような記述感になります(本家とは結構差異があります)。

type Users struct {
    ID        uint64 `migu:"autoincrement"`
    Name      string `migu:"size:64"`
    // snip...
}

type UsersIndex struct {
    Index00 interface{} `migu:"pk;index:PRIMARY,id"`
    Index01 interface{} `migu:"unique;index:idx_users_name,name"`
}

ログ

以下二つの対応を行ってログの制御をしています。

  • gorm.DBLogMode を環境変数から On / Off できるようにしている
  • gorm.LogFormatter 変数を独自の関数に置き換えている

前者の環境変数の注入は、 docker-compose.yml や ECS Task Definition で実現しています。

後者の制御は最近適用したもので、弊社 のアプリケーションログは Cloud Watch => Elasticsearch => Kibana という経路で流れていくようになっており、Elasticsearch のインデックス設計に合うような Json フォーマットに変換する関数に置き換える事によってよりよく検索できるようにした改善になります。

コネクションセットアップ

以下の関数を使って調整しています。アプリケーション側とDB側で無駄なくリソースを使うように調整しています(しようとしています)。

  • gorm.DB.SetConnMaxLifetime
  • gorm.DB.SetMaxIdleConns
  • gorm.DB.SetMaxOpenConns

エラーハンドリング

ここまで触れていませんでしたが、弊社では gorm.DB を直接使うのではなく薄くラップして使っています。いくつかある理由の一つが gorm.DB.Error にありました。

  • INSERT 時の Duplicate Entry を検出するコードが各所に分散してしまっていた
  • model 的な struct の validation error を検出するコードが各所に分散してしまっていた

そこで gorm.DB をラップする orm.DB を作成し、その関数として HasValidationErrorsIsDuplicateKeyError を提供する形にまとめることにより以下のような記述が可能になっています。

result := db.Create(&model) // db = orm.DB
if result.Error() != nil {
    if result.HasValidationErrors() {
        // validation error 時の処理
    }
    if result.IsDuplicateKeyError() {
        // Duplicate Entry 時の処理
    }
    return result.Error()
}

トランザクション

Gorm には Transaction 機能がありますが、各所に db.Begin, db.Rollback, db.Commit を記述する仕組みになっており、エラーが発生した際のロールバックを忘れる可能性がある設計になっています。
これを改善するため、上記でふれた gorm.DB をラップする orm.DBdefer db.Rollback と記述しておくことで必要であればロールバックするような仕掛けをいれました。この対応によりトランザクションの記述は以下のように記述すればよくなりスッキリしたかと思います。

tx := db.Begin()
defer tx.Rollback()

// いろんな処理

tx.Commit()

ちなみに、 Rollback という名前は実際にやっていることと一致していないので RollbackIfNeeded などにしたほうがよいのではないかという議論が起こったことがあります...

Validation

弊社 では Web Application Framework として Gin を使用しています。 Gin の validator は valiator.v8 というライブラリに依存する形になっており、 Gorm の validator を検討した際に struct tag の記述を一致させたほうがコードの読み書きをする上でわかりやすい、という判断で Gorm でも validator.v8 をつかうようになりました。

Gorm で利用するにあたり、新しく Validator 変数を用意し、struct tag の key を Gin と合わせて binding と記述できるように実装しています。

import v "gopkg.in/go-playground/validator.v8"

var Validator *v.Validate

func init() {
    Validator = v.New(&v.Config{TagName: "binding"})
}

各 model に相当する struct では、 Gorm の BeforeSave Callback を利用して validation をかけます。 binding struct tag の記述内容は validator.v8 に準じています。

type User struct {
    gorm.Model
    Name  string `binding:"required,min=1,max=64"`
    // snip...
}

func (m *User) BeforeSave() error {
    return Validator.Validate(m)
}

複雑なクエリの組み立て方

弊社 で提供しているサービスでは顧客企業側でサービス内のデータを様々な検索軸でフィルタしてリスト表示したり、エクセル出力するなどの機能を提供しています。
以前は、このような機能を提供する際に Gorm の Raw SQL (Raw + Scan あるいは Rows + Scan)で記述しがちだったため、

  • Soft Delete カラム(deleted_at)など、常に指定するべき Where 句を忘れがち
  • 変数や struct を都度用意しないといけないため記述が煩雑になりがち

という課題感がありましたが、最近になって Select, Where, Join などを都度 db に追加していくようにしたところスッキリしつつ、冗長な記述を削減できてよい感じがしています。実際に使用しているコードではなく擬似コードなので変なところがあるかもしれませんが、以下のような記述感になります。参考にしてみてください。

// ベースとなるクエリ
state := db.Where("aaa_id = ?", aaaID).Where("status in (?)", statuses)

// bbbIDs が指定された場合のみクエリに追加するシーン
if len(bbbIDs) != 0 {
    state = state.Where("bbb_id IN (?)", bbbIDs)
}

// さらに別テーブルへのサブクエリの結果も適用したいシーン
if len(cccIDs) != 0 {
    q := "ddd_id IN (SELECT ddd_id FROM c_d WHERE ccc_id IN (?))"
    state = state.Where(q, cccIDs)
}

// クエリ実行
selectState := state.
    Preload("OtherModel").
    Order(order).
    Offset(offset).
    Limit(limit)
results := make([]*ExampleModel, 0, limit)
if err := selectState.Find(&results).Error(); err != nil {
    return err
}

// 同じクエリを使って件数を取得(ページング用)
countState := state
total := uint(0)
if err := countState.Model(&ExampleModel{}).Count(&total).Error(); err != nil {
    return err
}

Preload したりしなかったりする(2018/4/6 追記)

Gorm には Preload という Eager Loading の仕組みがあります。サービス運営が続くうち Preload するかどうか以外は全部同じ SQL にしたい的な状況によく出くわすようになるかと思います。弊社では以下のような関数の書き方をするのが最近の流行です。

func FindByID(id uint64, preloads ...string) *XxxModel {
    m := XxxModel{}
    q := db  // gorm.DB のインスタンス
    for _, v := range preloads {
        q = q.Preload(v)
    }
    if q.First(&m, id).Error() != nil {
        return nil
    }
    return &m
}

// 呼び出し側
m := FindByID(1, "preloadしたいstruct1", "preloadしたいstruct2")

おわりに

SmartDrive ではバックエンドエンジニアの採用活動を積極的に行っております。
また、その他各種エンジニアも絶賛募集中です。ご興味がありましたら是非 ご応募 ください!!