Help us understand the problem. What is going on with this article?

Goの新定番?ORMのSQLBoilerを使ってみる

More than 1 year has passed since last update.

はじめに

GoのORMといえばGormが定番ですが、
SQLBoilerというORMを使ってみたので、そのレポートです。
https://github.com/volatiletech/sqlboiler

  • 既に存在するDBで動作する。DBスキーマを定義するツールではない。
  • ActiveRecordのような高い生産性を実現する。
  • Go本来のStructの使い勝手を壊さない。
  • パフォーマンスが良い。

といった特徴があるようです。
パフォーマンスについてはREADME末尾にベンチマーク結果が載せてあり、他のORMと比較しても非常に優秀です。
https://github.com/volatiletech/sqlboiler#benchmarks

この記事に掲載しているコードは、以下のリポジトリにあります。
https://github.com/uhey22e/sqlboiler-tutor

環境

  • Go v1.11
  • SQLBoiler v3.0.1
  • PostgreSQL v10.5

DBの準備

簡単なToDoアプリを模して、以下のようなスキーマを用意します。
DBはPostgreSQL 10.5で動作を確認しています。

sql/10_todo.sql
CREATE TABLE todo (
    id bigserial NOT NULL,
    title varchar(255) NOT NULL,
    note text,
    finished boolean NOT NULL,
    due_date timestamp,
    CONSTRAINT todo_pkc PRIMARY KEY(id)
);

コードの生成

SQLBoilerは、DBからテーブル構造を読み取り、各テーブルに対応するStructを生成します。
また、Structの自動生成と同時に、InsertやSelectなど各DB操作を行うメソッドも自動生成されます。
このような特徴から、SQLBoilerはリフレクションを用いる他のORMと違い、型安全で高速だそうです。

DBからコードを自動生成するために、まずは設定ファイル sqlboiler.tomlをプロジェクトルートに作成します。

$ cat sqlboiler.toml
[psql]
  dbname = "sample_db"
  host   = "localhost"
  port   = 5432
  user   = "postgres"
  pass   = "postgres"
  sslmode= "disable"
  blacklist = []

dbnamehostなどは接続先DBの情報です。
blacklistは、モデルを自動生成"しない"テーブルを指定することが出来ます。マイグレーション用のテーブルがある場合などに便利そうです。
また、whitelistというオプションを使うと、モデルを自動生成するテーブルを選択することが出来ます。blacklistとどちらか一方の指定となります。

設定ファイルが出来たら、コード生成です。
まずは必要なパッケージの入手から。
SQLBoilerと、PostgreSQL用のドライバをgo getします。

$ go get -u -t github.com/volatiletech/sqlboiler
$ go get github.com/volatiletech/sqlboiler/drivers/sqlboiler-psql

自動生成はsqlboilerコマンドで実行出来ますが、コマンドを忘れないようにgo generateを使いましょう。

main.go
package main
//go:generate sqlboiler --wipe psql

コード生成が成功すれば、以下のようなディレクトリとファイルが生成されているはずです。
todo.goに、テーブルと対応関係のあるStructやメソッドが実装されている状態です。

$ tree ./models
./models
├── boil_main_test.go
├── boil_queries.go
├── boil_queries_test.go
├── boil_suites_test.go
├── boil_table_names.go
├── boil_types.go
├── psql_main_test.go
├── psql_suites_test.go
├── psql_upsert.go
├── todo.go     # このファイルがテーブル"todo"に対応している
└── todo_test.go

生成されたStructは、以下の通りです。
カラム名はsnake_caseでしたが、フィールド名ではUpperCamelCaseに変換してくれています。
また、json用タグなども一緒に生成されていて便利そうです。
これらタグのcaseは、自動生成時のオプションでsnake_caseかcamelCaseを選択可能です。

models/todo.go
// いろいろ省略
type Todo struct {
    ID       int64       `boil:"id" json:"id" toml:"id" yaml:"id"`
    Title    string      `boil:"title" json:"title" toml:"title" yaml:"title"`
    Note     null.String `boil:"note" json:"note,omitempty" toml:"note" yaml:"note,omitempty"`
    Finished bool        `boil:"finished" json:"finished" toml:"finished" yaml:"finished"`
    DueDate  null.Time   `boil:"due_date" json:"due_date,omitempty" toml:"due_date" yaml:"due_date,omitempty"`

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

DB接続

SQLBoilerではDB接続に関する機能は用意されていませんので、
database/sqlを使用し、DBコネクションを作ります。

今回はgoのテストの仕組みを使ってコードを動かします。
AppというStructは、各機能をメソッドとして実装するためのものです。

todo/todo_test.go
func TestMain(m *testing.M) {
    // connect to db
    psqlInfo := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
        "localhost", 5432, "postgres", "postgres", "sample_db",
    )
    db, err := sql.Open("postgres", psqlInfo)
    if err != nil {
        panic(err)
    }
    defer db.Close()

    // validate whether or not the connection string was correct
    if err := db.Ping(); err != nil {
        panic(err)
    }

    app := &App{
        db: db,
    }
}

基本的なDB操作

Insert

Sliceを引数に取り、1件以上のTodoをDBに保存するメソッドを実装しました。

todo/todo.go
// Store stores a one or more todos into DB
func (a *App) Store(obj []*models.Todo) error {
    // insert for each todo
    for _, v := range obj {
        if err := v.Insert(context.Background(), a.db, boil.Infer()); err != nil {
            return err
        }
    }
    return nil
}

forループでInsert文を回しているのは、SQLBoilerにBulk insert機能が無いためです。
https://github.com/volatiletech/sqlboiler/issues/364
大量のデータをまとめてInsertする用途では、生SQLを書いたほうがいいかもしれません…

このメソッドを、以下のテストコードで試してみます。
null.TimeFromなどの関数は、Nullableなカラムの値を取り扱うためのnull.Timeなどの型に関連するものです。
https://github.com/volatiletech/null

todo/todo_test.go
func TestStore(t *testing.T) {
    // prepare sample insert data
    now := time.Now().UTC()
    todos := []*models.Todo{
        &models.Todo{
            Title:    "Sample ToDo 1",
            DueDate:  null.TimeFrom(now.Add(3 * time.Hour)),
            Note:     null.StringFrom("note1..."),
            Finished: false,
        },
        &models.Todo{
            Title:    "Sample ToDo 2",
            DueDate:  null.TimeFrom(now.Add(6 * time.Hour)),
            Note:     null.StringFrom("note2..."),
            Finished: false,
        },
    }

    // store
    if err := testApp.Store(todos); err != nil {
        t.Fatal(err)
    }

    for _, v := range todos {
        t.Log(fmt.Sprintf("%+v", v))
    }
}

無事にInsertできました。

sample_db=# select * from todo;
 id |     title     |   note   | finished |          due_date
----+---------------+----------+----------+----------------------------
  1 | Sample ToDo 1 | note1... | f        | 2018-09-15 14:34:58.605844
  2 | Sample ToDo 2 | note2... | f        | 2018-09-15 17:34:58.605844
(2 rows)

Select

finished == falseなレコードをSelectするメソッドを実装してみます。
Where句をオプションで指定するのですが、
github.com/volatiletech/sqlboiler/queries/qmを別途Importする必要があります。

Allメソッドはヒットするレコードが無いとエラーを返すのですが、
ヒットしない場合には空のスライスを返したいので、一度Countを実行しています。

todo/todo.go
// FetchUnfinished fetches unfinished ToDos
func (a *App) FetchUnfinished() ([]*models.Todo, error) {
        queries := []qm.QueryMod{
                qm.Where(models.TodoColumns.Finished+"=?", false),
        }

        // count up records
        counts, err := models.Todos(queries...).Count(context.Background(), a.db)
        if err != nil {
                return nil, err
        } else if counts == 0 {
                return make([]*models.Todo, 0), nil
        }

        // fetch!
        return models.Todos(queries...).All(context.Background(), a.db)
}

以下のテストコードで無事にSelectできました。
Structの自動生成時にjsonタグも付いてくるので、jsonエンコードもよしなにやってもらえます。

todo/todo_test.go
func TestFetchUnfinished(t *testing.T) {
    todos, err := testApp.FetchUnfinished()
    if err != nil {
        t.Fatal(err)
    }
    for _, v := range todos {
        var buf bytes.Buffer
        if err := json.NewEncoder(&buf).Encode(v); err != nil {
            t.Fatal(err)
        }
        t.Log(buf.String())
    }
}
$ go test -v ./todo --run TestFetchUnfinished
=== RUN   TestFetchUnfinished
--- PASS: TestFetchUnfinished (0.00s)
    todo_test.go:80: {"id":1,"title":"Sample ToDo 1","note":"note1...","finished":false,"dueDate":"2018-09-15T14:34:58.605844Z"}

    todo_test.go:80: {"id":2,"title":"Sample ToDo 2","note":"note2...","finished":false,"dueDate":"2018-09-15T17:34:58.605844Z"}

PASS
ok      github.com/uhey22e/sqlboiler-tutor/todo 0.010s

Update

1つ以上の指定したIDのToDoを、finished = trueにUpdateするメソッドを実装しました。
WhereInでIDを指定するのですが、このメソッドは引数が[]interface{}型のため、キャストが必要になります。
https://github.com/volatiletech/sqlboiler/issues/227
Updateするカラムと値の指定は、map[string]interface{}型の変数で行います。
Keyがカラム名、ValueがUpdate後の値です。

todo/todo.go
// Finish update one or more records selected by ID
func (a *App) Finish(ids []int64) error {
    // Set updating columns
    updCols := map[string]interface{}{
        models.TodoColumns.Finished: true,
    }

    // WhereIn method needs to pass a slice of interface{}
    targetIDs := make([]interface{}, len(ids))
    for i, v := range ids {
        targetIDs[i] = v
    }
    query := qm.WhereIn(models.TodoColumns.ID+" IN ?", targetIDs...)

    // update!
    _, err := models.Todos(query).UpdateAll(context.Background(), a.db, updCols)

    return err
}

以下のテストコードで無事にUpdateできました。

todo/todo_test.go
func TestFinish(t *testing.T) {
    if err := testApp.Finish([]int64{1, 2}); err != nil {
        t.Fatal(err)
    }
}
sample_db=# select * from todo;
 id |     title     |   note   | finished |          due_date
----+---------------+----------+----------+----------------------------
  1 | Sample ToDo 1 | note1... | t        | 2018-09-15 14:34:58.605844
  2 | Sample ToDo 2 | note2... | t        | 2018-09-15 17:34:58.605844
(2 rows)

コード生成のカスタマイズ

SQLBoilerのコード生成は、オプションによってカスタマイズすることが出来ます。
ここからがSQLBoilerの真髄…?
https://github.com/volatiletech/sqlboiler#controlling-generation

型の変更

PostgreSQLにてtimestamp型のカラムがあった場合、SQLBoilerはデフォルトではtime.Timeあるいはnull.Time型を使用しますが、
この型をカスタマイズすることが可能です。
https://github.com/volatiletech/sqlboiler#types

例えば、time.Time型をJSONエンコードする際は、UnixTimestampにしたいというシチュエーションを考えてみます。
JSONエンコードの挙動を変更するには、対象の型をembedした新たなStructを定義し、MarshalJSONメソッドを再実装することで、json.Marshalerインタフェースを実装する、というのがひとつのアプローチです。

このアプローチにて、null.Time型をembedしたNullTimeというStructを新たに定義し、SQLBoilerのコードでもこの型を使うようにしてみます。

types/time.go
package types

import (
    "encoding/json"
    "time"

    "github.com/volatiletech/null"
)

type NullTime struct {
    null.Time
}

func (t NullTime) MarshalJSON() ([]byte, error) {
    var ts int64
    if !t.Time.Valid {
        return json.Marshal(nil)
    }
    ts = t.Time.Time.Unix()
    return json.Marshal(ts)
}

func NullTimeFrom(t time.Time) NullTime {
    return NullTime{
        Time: null.TimeFrom(t),
    }
}

型のカスタマイズは以下のように、SQLBoilerの設定ファイルに追記します。

sqlboiler.toml
[[types]]
  [types.match]
    type = "null.Time"
    nullable = true

  [types.replace]
    type = "mytypes.NullTime"

  [types.imports]
    third_party = ['mytypes "github.com/uhey22e/sqlboiler-tutor/types"']

これでコードの再生成を行うと、モデルは以下のようになります。

models/todo.go
// Todo is an object representing the database table.
type Todo struct {
    ID       int64            `boil:"id" json:"id" toml:"id" yaml:"id"`
    Title    string           `boil:"title" json:"title" toml:"title" yaml:"title"`
    Note     null.String      `boil:"note" json:"note,omitempty" toml:"note" yaml:"note,omitempty"`
    Finished bool             `boil:"finished" json:"finished" toml:"finished" yaml:"finished"`
    DueDate  mytypes.NullTime `boil:"due_date" json:"dueDate,omitempty" toml:"dueDate" yaml:"dueDate,omitempty"`
    R        *todoR           `boil:"-" json:"-" toml:"-" yaml:"-"`
    L        todoL            `boil:"-" json:"-" toml:"-" yaml:"-"`
}

この状態で先程のFetchUnfinishedメソッドを実行すると、以下のような結果が得られます。メソッドの修正は必要ありません。
dueDateがきちんとUnixTimestampになっています。データが少し変わっているのは、null値のエンコードを見るためにデータを入れ直したためです。
多少であれば手書きでも良いかもしれませんが、テーブルが増えてきたときのことを考えると、この仕組みはありがたいです。

$ go test -v ./todo --run TestFetchUnfinished
=== RUN   TestFetchUnfinished
--- PASS: TestFetchUnfinished (0.00s)
    todo_test.go:86: {"id":1,"title":"Sample ToDo 1","note":"note1...","finished":false,"dueDate":1537086524}

    todo_test.go:86: {"id":2,"title":"Sample ToDo 2","note":"note2...","finished":false,"dueDate":1537097324}

    todo_test.go:86: {"id":3,"title":"Sample ToDo 3","note":"note2...","finished":false,"dueDate":null}

PASS
ok      github.com/uhey22e/sqlboiler-tutor/todo 0.009s

テーブル名のエイリアスの変更

SQLBoilerは、snake_caseのテーブル名・カラム名に対して、良い感じにUpperCamelCase/LowerCamelCaseの名前を生成してくれますが、これを変更することが出来ます。
https://github.com/volatiletech/sqlboiler#aliases

今回はtodoというテーブル名に対してTodoというStructを生成してくれていますが、これをToDoに、
finishedというカラム名に対してFinishedというフィールド名を生成してくれていますが、これをIsFinishedに変更してみます。

sqlboiler.toml
[aliases.tables.todo]
  up_plural = "ToDos"
  up_singular = "ToDo"
  down_plural = "todos"
  down_singular = "todo"

  [aliases.tables.todo.columns]
    finished = "IsFinished"

上記を設定ファイルに追記すると、Structは以下のようになります。

models/todo.go
type ToDo struct {
    ID         int64            `boil:"id" json:"id" toml:"id" yaml:"id"`
    Title      string           `boil:"title" json:"title" toml:"title" yaml:"title"`
    Note       null.String      `boil:"note" json:"note,omitempty" toml:"note" yaml:"note,omitempty"`
    IsFinished bool             `boil:"finished" json:"finished" toml:"finished" yaml:"finished"`
    DueDate    mytypes.NullTime `boil:"due_date" json:"dueDate,omitempty" toml:"dueDate" yaml:"dueDate,omitempty"`
    R          *todoR           `boil:"-" json:"-" toml:"-" yaml:"-"`
    L          todoL            `boil:"-" json:"-" toml:"-" yaml:"-"`
}

正直あまり使い所が思いつきませんが、
DBのカラム名が変更になった際、Goのコードを修正するのが大変なときに、一時しのぎとして使えるかもしれません。
あるいは、DBのテーブル名に諸々の都合でPrefix/Suffixをつけているが、Goのコードではこれを排除したい、という場合にも使えるかもしれません。

テンプレートの追加

自動生成のカスタマイズではカバーしきれない場合、自前でテンプレートを書くことで自動生成されるコードを追加することが出来ます。
https://github.com/volatiletech/sqlboiler#templates

先程のInsertのメソッドではfor文を書きましたが、
毎回似たようなコードを書くのも大変なので、これを自動生成してみます。
モデルのSliceに対してメソッドを追加する形でテンプレートを書いてみました。

boil_templates/90)multi_insert.go.tpl
{{- $alias := .Aliases.Table .Table.Name}}

// Insert inserts multiple records
func (o {{$alias.UpSingular}}Slice) Insert({{if .NoContext}}exec boil.Executor{{else}}ctx context.Context, exec boil.ContextExecutor{{end}}, columns boil.Columns) error {
    for _, v := range o {
        if err := o.Insert({{if not .NoContext}}ctx, {{end -}} exec, columns); err != nil {
            return err
        }
    }
    return nil
}

テンプレートを追加した場合には、それを保存したディレクトリを生成時に指定する必要があります。
標準のテンプレートも指定する必要があるのでご注意を。

sqlboiler --wipe \
--templates ${GOPATH}/src/github.com/volatiletech/sqlboiler/templates,\
${GOPATH}/src/github.com/volatiletech/sqlboiler/templates_test,\
${GOPATH}/src/github.com/uhey22e/sqlboiler-tutor/boil_templates \
--struct-tag-casing camel psql

生成されたコードは以下です。

models/todo.go
// Insert inserts multiple records
func (o ToDoSlice) Insert(ctx context.Context, exec boil.ContextExecutor, columns boil.Columns) error {
    for _, v := range o {
        if err := o.Insert(ctx, exec, columns); err != nil {
            return err
        }
    }
    return nil
}

ただfor文を回すだけです。Insert文は減りません。
今回はテーブルをひとつしか使っていませんが、すべてのテーブルに対して同様のコードが生成されます。

おわりに

他言語のORMを触った経験が少ないため、比較はあまり出来ませんが、
リフレクションを使った複雑なコードになっていないため、
DB周りで何かおかしなことがあったときに原因が追いやすい、というのはあると思います。

今回はひとつのテーブルに対しての操作しか試していませんが、
Eager Loadingも実装されているようなので、リレーションについても追って試してみたいと思います。

uhey22e
future
ITを武器とした課題解決型のコンサルティングサービスを提供します
http://future-architect.github.io/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away