nがひとつ多い。

えぬなおの技術的なことを書いていくとこ。

Goでxo/xo入門

はじめに

この記事はGo Advent Calendar 2018の19日目の記事です。
結構今更ですが、僕は本番環境で投入しているものの、あんまり知られてないようなので改めて書いてみます。

自動DBモデリングツールxo/xoの紹介

github.com

ラーメンを美味しく料理するための香辛料ではなくてですね、

xo is a command-line tool to generate Go code based on a database schema or a custom query.

動いている任意のDBエンジンからテーブルを抽出して、Goの構造体とかをコードで生成するツールです。
みなさんはどうやってから構造体を生成してますか? どーのこーのして生成しているでしょうが、hoge_countがbigint型だから〜goだとintか〜あーUNSIGNEDだからuintか〜とか考えながら書き書きするとしたら、ちょっと面倒だなって感じです。

そこをいい感じに動いているDBから自動的に作ってくれるもんで、非常に楽になるわけです。

色々なDBに対応

公式のREADMEに書いてあるんですが、

がサポートされています。すごいですね。
もちろん機能的なサポートは各DB異なりますが、詳細は公式をどうぞ。

インストール

ツールも当然Goで書かれているのでインストールは簡単です。

$ go get -u github.com/xo/xo

# install with oracle support (see notes below)
$ go get -tags oracle -u github.com/xo/xo

はい、これで xo コマンドが使えるようになりました。

DBの用意

当然ながらDBを用意していただく必要があります。 便利な時代なので docker を使います。
今回はみんな大好き mysql を選びました。

$ docker run --name test-mysql --rm -d -e MYSQL_ROOT_PASSWORD=my-pw -p 3306:3306 mysql:8.0.0

環境変数とかパスワードとかバージョンは適当に変えてください。

DBの下準備

動いているDBから自動的にモデルとなるコードを生成してくれる コードなもんで、
適当なDBとテーブルを作っておきます。

まずmysqlに繋いで、

$ mysql -uroot -p'my-pw' -h 0.0.0.0

テスト用のDBとテーブルを作ってみました。

CREATE DATABASE IF NOT EXISTS `test-xo-db`;
USE `test-xo-db`

CREATE TABLE `test-xo-table` (
  id int(11) NOT NULL,
  hoge_count int(11) NOT NULL,
  delete_flg int(1) UNSIGNED  NOT NULL,
  created datetime NOT NULL,
  updated datetime NOT NULL
);

ALTER TABLE `test-xo-table`
 ADD PRIMARY KEY (`id`);

ALTER TABLE `test-xo-table`
  ADD INDEX index_name(`delete_flg`);

そこらへんに転がってそうなテーブルですね。

げってぃんぐすたーてっど

まず適当なプロジェクトを GOPATH 配下とかに作ります。

$ mkdir -p $GOPATH/path/to/my-project/models
$ cd $GOPATH/path/to/my-project

そうしたら、早速DBのモデルを作ってしまいましょう。

$ xo 'mysql://root:my-pw@0.0.0.0/test-xo-db' -o models

上記のコマンドも、環境ごとに変えてください、いつものやつです。

そうすると以下のようなファイルがmodels配下にできます。

$ ls -l models
-rw-r--r--  1 nnao45  nnao45  4242 12 18 17:33 testxotable.xo.go
-rw-r--r--  1 nnao45  nnao45  2137 12 18 17:33 xo_db.xo.go

中をみてみましょう。

testxotable.xo.go

// Package models contains the types for schema 'test-xo-db'.
package models

// Code generated by xo. DO NOT EDIT.

import (
        "errors"
        "time"
)

// TestXoTable represents a row from 'test-xo-db.test-xo-table'.
type TestXoTable struct {
        ID        int       `json:"id"`         // id
        HogeCount int       `json:"hoge_count"` // hoge_count
        DeleteFlg uint      `json:"delete_flg"` // delete_flg
        Created   time.Time `json:"created"`    // created
        Updated   time.Time `json:"updated"`    // updated

        // xo fields
        _exists, _deleted bool
}

// Exists determines if the TestXoTable exists in the database.
func (txt *TestXoTable) Exists() bool {
        return txt._exists
}

// Deleted provides information if the TestXoTable has been deleted from the database.
func (txt *TestXoTable) Deleted() bool {
        return txt._deleted
}

// Insert inserts the TestXoTable to the database.
func (txt *TestXoTable) Insert(db XODB) error {
        var err error

        // if already exist, bail
        if txt._exists {
                return errors.New("insert failed: already exists")
        }

        // sql insert query, primary key must be provided
        const sqlstr = `INSERT INTO test-xo-db.test-xo-table (` +
                `id, hoge_count, delete_flg, created, updated` +
                `) VALUES (` +
                `?, ?, ?, ?, ?` +
                `)`

        // run query
        XOLog(sqlstr, txt.ID, txt.HogeCount, txt.DeleteFlg, txt.Created, txt.Updated)
        _, err = db.Exec(sqlstr, txt.ID, txt.HogeCount, txt.DeleteFlg, txt.Created, txt.Updated)
        if err != nil {
                return err
        }

        // set existence
        txt._exists = true

        return nil
}

// Update updates the TestXoTable in the database.
func (txt *TestXoTable) Update(db XODB) error {
        var err error

        // if doesn't exist, bail
        if !txt._exists {
                return errors.New("update failed: does not exist")
        }

        // if deleted, bail
        if txt._deleted {
                return errors.New("update failed: marked for deletion")
        }

        // sql query
        const sqlstr = `UPDATE test-xo-db.test-xo-table SET ` +
                `hoge_count = ?, delete_flg = ?, created = ?, updated = ?` +
                ` WHERE id = ?`

        // run query
        XOLog(sqlstr, txt.HogeCount, txt.DeleteFlg, txt.Created, txt.Updated, txt.ID)
        _, err = db.Exec(sqlstr, txt.HogeCount, txt.DeleteFlg, txt.Created, txt.Updated, txt.ID)
        return err
}

// Save saves the TestXoTable to the database.
func (txt *TestXoTable) Save(db XODB) error {
        if txt.Exists() {
                return txt.Update(db)
        }

        return txt.Insert(db)
}

// Delete deletes the TestXoTable from the database.
func (txt *TestXoTable) Delete(db XODB) error {
        var err error

        // if doesn't exist, bail
        if !txt._exists {
                return nil
        }

        // if deleted, bail
        if txt._deleted {
                return nil
        }

        // sql query
        const sqlstr = `DELETE FROM test-xo-db.test-xo-table WHERE id = ?`

        // run query
        XOLog(sqlstr, txt.ID)
        _, err = db.Exec(sqlstr, txt.ID)
        if err != nil {
                return err
        }

        // set deleted
        txt._deleted = true

        return nil
}

// TestXoTablesByDeleteFlg retrieves a row from 'test-xo-db.test-xo-table' as a TestXoTable.
//
// Generated from index 'index_name'.
func TestXoTablesByDeleteFlg(db XODB, deleteFlg uint) ([]*TestXoTable, error) {
        var err error

        // sql query
        const sqlstr = `SELECT ` +
                `id, hoge_count, delete_flg, created, updated ` +
                `FROM test-xo-db.test-xo-table ` +
                `WHERE delete_flg = ?`

        // run query
        XOLog(sqlstr, deleteFlg)
        q, err := db.Query(sqlstr, deleteFlg)
        if err != nil {
                return nil, err
        }
        defer q.Close()

        // load results
        res := []*TestXoTable{}
        for q.Next() {
                txt := TestXoTable{
                        _exists: true,
                }

                // scan
                err = q.Scan(&txt.ID, &txt.HogeCount, &txt.DeleteFlg, &txt.Created, &txt.Updated)
                if err != nil {
                        return nil, err
                }

                res = append(res, &txt)
        }

        return res, nil
}

// TestXoTableByID retrieves a row from 'test-xo-db.test-xo-table' as a TestXoTable.
//
// Generated from index 'test-xo-table_id_pkey'.
func TestXoTableByID(db XODB, id int) (*TestXoTable, error) {
        var err error

        // sql query
        const sqlstr = `SELECT ` +
                `id, hoge_count, delete_flg, created, updated ` +
                `FROM test-xo-db.test-xo-table ` +
                `WHERE id = ?`

        // run query
        XOLog(sqlstr, id)
        txt := TestXoTable{
                _exists: true,
        }

        err = db.QueryRow(sqlstr, id).Scan(&txt.ID, &txt.HogeCount, &txt.DeleteFlg, &txt.Created, &txt.Updated)
        if err != nil {
                return nil, err
        }

        return &txt, nil
}

xo_db.xo.go

// Package models contains the types for schema 'test-xo-db'.
package models

// Code generated by xo. DO NOT EDIT.

import (
        "database/sql"
        "database/sql/driver"
        "encoding/csv"
        "errors"
        "fmt"
        "regexp"
        "strings"
)

// XODB is the common interface for database operations that can be used with
// types from schema 'test-xo-db'.
//
// This should work with database/sql.DB and database/sql.Tx.
type XODB interface {
        Exec(string, ...interface{}) (sql.Result, error)
        Query(string, ...interface{}) (*sql.Rows, error)
        QueryRow(string, ...interface{}) *sql.Row
}

// XOLog provides the log func used by generated queries.
var XOLog = func(string, ...interface{}) {}

// ScannerValuer is the common interface for types that implement both the
// database/sql.Scanner and sql/driver.Valuer interfaces.
type ScannerValuer interface {
        sql.Scanner
        driver.Valuer
}

// StringSlice is a slice of strings.
type StringSlice []string

// quoteEscapeRegex is the regex to match escaped characters in a string.
var quoteEscapeRegex = regexp.MustCompile(`([^\\]([\\]{2})*)\\"`)

// Scan satisfies the sql.Scanner interface for StringSlice.
func (ss *StringSlice) Scan(src interface{}) error {
        buf, ok := src.([]byte)
        if !ok {
                return errors.New("invalid StringSlice")
        }

        // change quote escapes for csv parser
        str := quoteEscapeRegex.ReplaceAllString(string(buf), `$1""`)
        str = strings.Replace(str, `\\`, `\`, -1)

        // remove braces
        str = str[1 : len(str)-1]

        // bail if only one
        if len(str) == 0 {
                *ss = StringSlice([]string{})
                return nil
        }

        // parse with csv reader
        cr := csv.NewReader(strings.NewReader(str))
        slice, err := cr.Read()
        if err != nil {
                fmt.Printf("exiting!: %v\n", err)
                return err
        }

        *ss = StringSlice(slice)

        return nil
}

// Value satisfies the driver.Valuer interface for StringSlice.
func (ss StringSlice) Value() (driver.Value, error) {
        v := make([]string, len(ss))
        for i, s := range ss {
                v[i] = `"` + strings.Replace(strings.Replace(s, `\`, `\\\`, -1), `"`, `\"`, -1) + `"`
        }
        return "{" + strings.Join(v, ",") + "}", nil
}

// Slice is a slice of ScannerValuers.
type Slice []ScannerValuer

しゅ、しゅごい・・・・!

生み出されるコードのうち重要な部分の紹介

type XODB interface

// XODB is the common interface for database operations that can be used with
// types from schema 'test-xo-db'.
//
// This should work with database/sql.DB and database/sql.Tx.
type XODB interface {
        Exec(string, ...interface{}) (sql.Result, error)
        Query(string, ...interface{}) (*sql.Rows, error)
        QueryRow(string, ...interface{}) *sql.Row
}

XODB と言うインターフェスが生成されますが、
基本的にはこのインターフェイスを通して、他の箇所で初期化し終わった sql.DB 型の変数を通していく事となります。

XOLog

// XOLog provides the log func used by generated queries.
var XOLog = func(string, ...interface{}) {}

XOLog は関数です。よく見ると何もありませんが、これは開発者が好きなログライブラリによる関数を突っ込めるように何も定義されていないのです。つまりこれを、

var XOLog = func(str string, args ...interface{}) {
    log.Infof("Run Query: %s, With Args: %v", str, args)
}

みたいな感じに定義してあげる事で好きなようにカスタマイズできるわけです。

xoをカスタマイズする。

さて、xo/xoの入門したのちにあることをに気づきます。

「あれ・・・?これって毎回モデリングして初期化した時に、どうやって新しく関数を定義すればいいの・・・?」

ここで、出てくるのが、goの template です。 (goの template って言うのは自動でgoのコードを生み出しまくる、jinja2使ったことがあればそれです)

xoのtemplateを指定する。

https://github.com/xo/xo/tree/master/templates

xo/xo レポジトリのtemplatesディレクトリに、デフォルトで使われている template が置いてあります。

xo コマンドで任意のtemplateディレクトリを指定して生成する場合、以下のように行います。

$ xo 'mysql://root:my-pw@0.0.0.0/test-xo-db' -o models --template-path templates/

こうすることによって、カスタマイズされたテンプレートを使うことができます。

例えば、XODB、XOLogを拡張してみる。

先ほどの xo/xo レポジトリのtemplatesディレクトリの xo_db.go.tplxo_package.go.tpl を編集してみます。

// XODB is the common interface for database operations that can be used with
// types from schema '{{ schema .Schema }}'.
//
// This should work with database/sql.DB and database/sql.Tx.
type XODB interface {
    Exec(string, ...interface{}) (sql.Result, error)
    Query(string, ...interface{}) (*sql.Rows, error)
    QueryRow(string, ...interface{}) *sql.Row
    QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
    QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}

// XOLog provides the log func used by generated queries.
var XOLog = func(str string, args ...interface{}) {
    log.Infof("Run Query: %s, With Args: %v", str, args)
}
// Package {{ .Package }} contains the types for schema '{{ schema .Schema }}'.
package {{ .Package }}

// Code generated by xo. DO NOT EDIT.

import (
    "database/sql"
    "database/sql/driver"
    "encoding/csv"
    "errors"
    "fmt"
    "regexp"
    "strings"
    "time"
    "context"
    "log"
)

簡単にできました。これで、コンテキスト付きのクエリ発行やロギング使用ができることが可能です。
もちろん、どちらもテンプレート内にある仕様やインターフェースを満たしている範囲での編集をしている事に注意してください。

例えば、独自関数を拡張してみる。

mysqlの場合は、まぁ適当でいいんですが、 mysql.type.go.tpl とかに追記しておくと、どんなテーブルでも独自関数が生成されるようになります。

例えばフルスキャンするような関数を定義したい場合は以下のように追記します。

// {{ .Name }}ByAll retrieves all rows from '{{ $table }}'
func {{ .Name }}ByAll(db XODB) ([]*{{ .Name }}, error) {
    // sql query
    const sqlstr = `SELECT ` +
        `{{ colnames .Fields }} ` +
        `FROM {{ $table }} `

    // define context
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // run query
    XOLog(sqlstr)
    result, err := db.QueryContext(ctx, sqlstr)
    if err != nil {
        return []*{{ .Name }}{}, err
    }
    {{ $short }}l := []*{{ .Name }}{}

    for result.Next() {
        {{ $short }} := &{{ .Name }}{
            _exists: true,
        }

        if err := result.Scan({{ fieldnames .Fields (print "&" $short) }}); err != nil {
            return []*{{ .Name }}{}, err
        }

        {{ $short }}l = append({{ $short }}l, {{ $short }})
    }

    return {{ $short }}l, nil
}

注意としては、goのtemplateのように定義された構造体のキーを {{ .Name }} で引いていたり、
{{ $short }}変数に代入しているので、一見非常にわかりにくいです。

でも、各種他のデフォルトで定義されているものを参考にして試行錯誤すれば、あまり苦労せずにかけると思います。

終わりに。

xo/xo を使ってDBから動的にgoのコードを生み出す方法をみました。
非常に楽できますが、モデル生成後は生み出したコードを編集せずに使う前提なので癖も強く、
依存性も強いので、しっかり動きを理解することで本番環境に入れることをお勧めします。