GoのORM、SQLBoilerのススメ

この記事は suusan2go Advent Calendar 2019 - Adventar の4日目の記事です。

フリーランス始めてから、エムスリーでお世話になった @maeharin さんがCTOをしてる ANNONE という会社をお手伝いしています。自分はその中で、Goによるアプリ向けのAPI,Reactによる管理画面、Flutterアプリ、CloudSQL=> BQの同期、新規事業のRails newなどと幅広く色々とやらせてもらっているのですが、今回はあのね というアプリ向けに作られたGoのAPIのDBアクセスをSQLBoilerに切り替えた話をします。

Go APIの構成

特定のフレームワークは使っておらずルーティングには gorilla/mux、DBアクセスには sqlxを使っていて、シンプルなレイヤードアーキテクチャを採用した以下のような構成になっています。

api
├── application
├── cmd
├── controller
├── domain
├── middleware
├── migration
├── repository
├── util
└── view

DBアクセスはrepositoryに記載されたinterfaceを実装する形で実現されています。

type Repository interface {
    FindClinicByID(q sqlx.Queryer, clinicID int64) (*domain.Clinic, error)
    CreateClinic(tx *sqlx.Tx, clinic *domain.Clinic) (int64, error)
}
// FindClinicByID IDでクリニックを検索する
func (r *repository) FindClinicByID(q sqlx.Queryer, clinicID int64) (*domain.Clinic, error) {
    c := domain.Clinic{}
    query := `
      select
          c.*
      from clinics as c
      where
          c.id = $1
  `
    if err := sqlx.Get(q, cd, query, clinicID); err != nil {
        if err == sql.ErrNoRows {
            return nil, errors.WithStack(NewRecordNotFoundError(fmt.Sprintf("clinic(id: %d) is not found", clinicID)))
        }
        return nil, errors.WithStack(err)
    }
    return cd, nil
}

// CreateClinic クリニックを登録する
func (r *repository) CreateClinic(tx *sqlx.Tx, diary *domain.Clinic) (int64, error) {
    query := `
      insert into clinics(
          , name
          , create_timestamp
          , update_timestamp
      ) values (
          , :name
          , :create_timestamp
          , :update_timestamp
      )
      returning id
  `

    stmt, err := tx.PrepareNamed(query)
    defer stmt.Close()
    if err != nil {
        return int64(0), errors.WithStack(err)
    }
    var id int64
    err = stmt.Get(&id, &diary)
    if err != nil {
        return int64(0), errors.WithStack(err)
    }

    return id, nil
}

sqlxによるDBアクセスのpros / cons

Goではありませんが、過去のプロジェクトでDomaなどSQLを書いてそれをオブジェクトにマッピングする形式のライブラリは使ったことがあり、SQLを書いてオブジェクトにマッピングするという手法はシンプルで気に入っています。特に複数のテーブルをジョインしてオブジェクトにマッピングしたいといったこともシンプルに実現できますし、コードを読めばどんなクエリを発行しようとしてるのかすぐに見える点もよいです。

しかしながら、スタートアップで開発スピードが要求され、変更も多い環境のなかでは、辛い点も見えてきました。

Insert / Updateの記述が辛い

上にも書きましたが、テーブルが増える度に以下のようなクエリとそれをマッピングするコードを毎回1から書く必要があるのは結構しんどいものがあります。カラムが増えたり変わった場合にはも他のクエリで使っているinsert / update文も忘れずに追随していかなければいけません。

 query := `
        insert into clinics(
            , name
            , create_timestamp
            , update_timestamp
        ) values (
            , :name
            , :create_timestamp
            , :update_timestamp
        )
        returning id
    `

そもそも開発初期段階や / 70%の機能ではシンプルなCRUDで十分なケースも多い

柔軟にSelect文がかけるというのはとても良い体験なのですが、全てのテーブルでSelect文をガッツリ書く必要があるかというとそんなことはなく、体感的には70%以上の機能ではシンプルなCRUDがシュッと実現できればそれで十分という感覚があります。むしろ一つテーブルを追加する度にCRUDSQLとstructにマッピングするコードのボイラープレートを大量に書く必要があるのは、特に新規機能開発で2、3のテーブルを追加する必要がある場合には結構ストレスでした。

TypeSafeではない

最初の問題点にも繋がりますが、Create文やUpdate文を長々と書く必要がある割にカラム名TypoしたりDBと対応の異なる型をStruct側に定義してしまったりしてもコンパイル時には気が付けません(これを Type Safeでないと言っていいのかわかりませんが・・・)。カラム追加や変更が合った場合に特にInsert / Update文で間違えなくこれに追随していくのはかなり大変に思えました。

SQLBoiler

上記にあげた課題を全て解決しつつ、普通にSelect文が書こうと思えば書けるようなツールはないだろうかと調べていたところで、以下のブログで紹介されているSQLBoilerというツールにたどり着きました。

Go の ORM / query builder 消耗日記 - blog.izum.in

github.com

SQLBoilerとは

SQLBoiler is a tool to generate a Go ORM tailored to your database schema. とあるように、DBのスキーマをもとにテーブルに対応するstructとCRUDな操作を提供するfunctionを自動生成してくれます。

REATE TABLE pilots (
  id integer NOT NULL,
  name text NOT NULL
);

ALTER TABLE pilots ADD CONSTRAINT pilot_pkey PRIMARY KEY (id);

CREATE TABLE jets (
  id integer NOT NULL,
  pilot_id integer NOT NULL,
  age integer NOT NULL,
  name text NOT NULL,
  color text NOT NULL
);

ALTER TABLE jets ADD CONSTRAINT jet_pkey PRIMARY KEY (id);
ALTER TABLE jets ADD CONSTRAINT jet_pilots_fkey FOREIGN KEY (pilot_id) REFERENCES pilots(id);

生成されるコードはこんな感じです。これとは別にFind等のメソッドがたくさん生成されます。

type Pilot struct {
  ID   int    `boil:"id" json:"id" toml:"id" yaml:"id"`
  Name string `boil:"name" json:"name" toml:"name" yaml:"name"`

  R *pilotR `boil:"-" json:"-" toml:"-" yaml:"-"`
  L pilotR  `boil:"-" json:"-" toml:"-" yaml:"-"`
}

type pilotR struct {
  Licenses  LicenseSlice
  Languages LanguageSlice
  Jets      JetSlice
}

type Jet struct {
  ID      int    `boil:"id" json:"id" toml:"id" yaml:"id"`
  PilotID int    `boil:"pilot_id" json:"pilot_id" toml:"pilot_id" yaml:"pilot_id"`
  Age     int    `boil:"age" json:"age" toml:"age" yaml:"age"`
  Name    string `boil:"name" json:"name" toml:"name" yaml:"name"`
  Color   string `boil:"color" json:"color" toml:"color" yaml:"color"`

  R *jetR `boil:"-" json:"-" toml:"-" yaml:"-"`
  L jetR  `boil:"-" json:"-" toml:"-" yaml:"-"`
}

type jetR struct {
  Pilot *Pilot
}

DBとの対応だけではなくJSONのタグも記述してくれるので、自分は後述する通り使っていませんがDBで取得した結果をそのままレスポンスとして返せばいいようなシンプルなアプリでは便利かもしれません。外部キー制約をもとにrelationを自動で貼ってくれ、nullableなものには null パッケージを適用してくれます。

基本的なデータの操作

詳細はREADMEに譲りますが、SQLBoilerでコードを生成すると以下のような操作は全て自動生成されたstructと関数だけで実装することが可能になります。

// IDで引く
clinicl, err := models.FindClinic(db,  clinicID)
// 全て取得する
clinics, err: = models.Clinics().All( db)
// where
clinics, err := models.Clinics(qm.Where("pref_id = ?", prefID), qm.And("name = ?", name)).One(db)
// insert
err :=clinicl.Insert(tx, boil.Infer())
// update
_, err := clinic.UPdate(tx, boil.Infer())
// Relationships
prefecture, err := clinic.Prefecture()

また必要なときにはsqlxのようにSQLを書いてオブジェクトにマッピングするという手法を取ることも可能です

// Custom struct for selecting a subset of data
type JetInfo struct {
  AgeSum int `boil:"age_sum"`
  Count int `boil:"juicy_count"`
}

var info JetInfo

// Use query building
err := models.NewQuery(Select("sum(age) as age_sum", "count(*) as juicy_count", From("jets"))).Bind(ctx, db, &info)

// Use a raw query
err := queries.Raw(`select sum(age) as "age_sum", count(*) as "juicy_count" from jets`).Bind(ctx, db, &info)

あのねアプリ向けAPIでの使い方

ActiveRecord-like productivity と語っているため?かはわかりませんが、SQLBoilerは自動生成したstructを使ってDBの内容をそのままJSONにして返すということも可能な作りになっています。しかし、以下の観点から元々存在していたrepositoryレイヤを活用してSQLBoilerの依存範囲をrepositoryパッケージにとどめています。

  • もともと存在するrepositoryレイヤを活用すれば、sqlxから安全に移行することが可能であること
  • SQLBoilerへのアプリケーションの依存を強めると、SQLBoilerから脱却することが難しくなること
  • Go言語の性質上、生成されたstructの挙動をオーバーライドするような実装をすることが難しいこと

実装としては以下のような感じになっており、アプリケーション用のstructである domain.Clinic をそのままSQLBoilerに渡さずmodels.Clinicマッピングし、また自動生成された models.Clinicをrepositoryの外には出さずに domain.Clinic し直すという感じになっています。sqlxのときにSQLを書いていたのがオブジェクトのマッピングに変わってしまっているといえばそうなのですが、Goのコード上でかけるのでIDEのサポートでfill-structといったツールを使ってガッとstructを埋めることもできますし、何よりちゃんと補完が聞くのでSQLを生で書いたいたときよりもかなり生産性があがりました。

// CreateClinic クリニック情報を登録する
func (r *repository) CreateClinic(tx boil.Executor, clinic *domain.Clinic) (int, error) {
    model := models.Clinic{
        Name:            clinic.Name,
        CreateTimestamp: time.Now(),
        UpdateTimestamp: time.Now(),
    }
    err := model.Insert(tx, boil.Infer())
    if err != nil {
        return 0, errors.WithStack(err)
    }

    return model.ID, nil
}

// FindClinicByID IDでクリニックを検索する
func (r *repository) FindClinicByID(q boil.Executor, clinicID int) (*domain.Clinic, error) {
    model, err := models.FindClinic(q, int(clinicID))
    if err != nil {
        return nil, errors.WithStack(err)
    }
    clinic := mapToClinic(*model)
    return clinic, nil
}

structの詰め替えをするのは非効率ですし、大量のデータを処理しなければいけないときにはパフォーマンス上の問題となる可能性もありますが、通常のアプリ向けAPIでは問題になるケースはそれほど多くないのではないでしょうか。

使ってみた所感

元々の狙いどおり、sqlxで辛さを感じていた箇所はSQLBoilerでかなり楽をすることができるようになりました。一方でSQLBoilerも万能ではなく、例えば現在のところLeft Outer Joinをサポートしていません。

github.com

複雑なクエリを発行する必要がある場合や上述したstructの詰め替えコストを許容できない場合には、sqlxは引き続きよい選択肢なのかなと思います。場合によっては併用もありなのかなーと思いつつ一つのアプリで2つのDBライブラリを要求するのはなぁ・・・みたいなことを思っています。

PR枠

ANNONE では、こんな技術を使って開発してます。

全方面エンジニア募集中のようなので、興味のある方はぜひ @maeharin さんにDMを!