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

# はじめに

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

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

# マイグレーション

[Gorm](https://github.com/jinzhu/gorm) には [Auto Migration 機能](http://jinzhu.me/gorm/database.html#migration) が組み込まれていますが、[弊社](https://qiita.com/organizations/smartdrive) ではこの機能は利用しておらず、替わりに [migu](https://github.com/astronoka/migu) をカスタマイズして利用しています。

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

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

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

```go
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.DB` の `LogMode` を環境変数から On / Off できるようにしている
- `gorm.LogFormatter` 変数を独自の関数に置き換えている

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

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

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

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

- `gorm.DB.SetConnMaxLifetime`
- `gorm.DB.SetMaxIdleConns`
- `gorm.DB.SetMaxOpenConns`

# エラーハンドリング

ここまで触れていませんでしたが、[弊社](https://qiita.com/organizations/smartdrive)では `gorm.DB` を直接使うのではなく薄くラップして使っています。いくつかある理由の一つが `gorm.DB.Error` にありました。

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

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

```go
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](https://github.com/jinzhu/gorm) には [Transaction](http://jinzhu.me/gorm/advanced.html#transactions) 機能がありますが、各所に `db.Begin`, `db.Rollback`, `db.Commit` を記述する仕組みになっており、エラーが発生した際のロールバックを忘れる可能性がある設計になっています。
これを改善するため、上記でふれた `gorm.DB` をラップする `orm.DB` で `defer db.Rollback` と記述しておくことで必要であればロールバックするような仕掛けをいれました。この対応によりトランザクションの記述は以下のように記述すればよくなりスッキリしたかと思います。

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

// いろんな処理

tx.Commit()
```

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

# Validation

[弊社](https://qiita.com/organizations/smartdrive) では Web Application Framework として [Gin](https://github.com/gin-gonic/gin) を使用しています。 Gin の validator は [valiator.v8](https://github.com/go-playground/validator/tree/v8) というライブラリに依存する形になっており、 Gorm の validator を検討した際に struct tag の記述を一致させたほうがコードの読み書きをする上でわかりやすい、という判断で Gorm でも validator.v8 をつかうようになりました。

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

```go
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 に準じています。

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

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

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

[弊社](https://qiita.com/organizations/smartdrive) で提供しているサービスでは顧客企業側でサービス内のデータを様々な検索軸でフィルタしてリスト表示したり、エクセル出力するなどの機能を提供しています。
以前は、このような機能を提供する際に Gorm の [Raw SQL](http://jinzhu.me/gorm/advanced.html#sql-builder) (`Raw` + `Scan` あるいは `Rows` + `Scan`)で記述しがちだったため、

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

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

```go
// ベースとなるクエリ
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](http://gorm.io/docs/preload.html) という Eager Loading の仕組みがあります。サービス運営が続くうち Preload するかどうか以外は全部同じ SQL にしたい的な状況によく出くわすようになるかと思います。弊社では以下のような関数の書き方をするのが最近の流行です。

```golang
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 ではバックエンドエンジニアの採用活動を積極的に行っております。
また、その他各種エンジニアも絶賛募集中です。ご興味がありましたら是非 [ご応募](https://smartdrive.co.jp/careers) ください!!