Goでxo/xo入門
はじめに
この記事はGo Advent Calendar 2018の19日目の記事です。
結構今更ですが、僕は本番環境で投入しているものの、あんまり知られてないようなので改めて書いてみます。
自動DBモデリングツールxo/xoの紹介
ラーメンを美味しく料理するための香辛料ではなくてですね、
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.tpl
と xo_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のコードを生み出す方法をみました。
非常に楽できますが、モデル生成後は生み出したコードを編集せずに使う前提なので癖も強く、
依存性も強いので、しっかり動きを理解することで本番環境に入れることをお勧めします。