こんなデータベース用ライブラリを誰か作ってほしい(Go)
Go2 Advent Calendar 2019 の 6 日目の記事です。
Go の database/sql
って使いにくくないでしょうか。
二年ちょっと前にもっと楽にできないかなと思って調べました。
欲しかったもの
database/sql
を使いやすくしたもの- ORM は要らない
- ただし、SELECT と INSERT は楽をしたい
ORM を必須とする人もいると思いますが、そのときは直に SQL 文を書きたかったので、それに合ったライブラリだけを試しました。
試したライブラリ
というわけで gorp を選び、そのときに書いた記事が下記二つです。
gorp の問題点
しかし、大きな問題を見つけました。 なんと gorp は Bulk Insert に対応していなかったのです。(´・ω・`)
一度に INSERT したいデータをスライスに入れて渡すと一応できたのですが、大量のデータだと非常に遅くなってしまいました。 うろ覚えですが、gorp のコードを見てみると 1 データずつ INSERT しているようでした。 *1
自分で改良して貢献するチャンスだと思ったのですが、力不足で無理でした。 gorp のユーザはどうしているのでしょうか。
@mattn さんがつい最近解決されていたことに先ほど気づきました。 話が止まってしまっているように見えますが、gorp に取り込まれるといいなと思います。
Multiple insert · Issue #313 · go-gorp/gorp · GitHub
自分で作りました
タイトルで「誰か作ってほしい」としましたが、実は gorp の問題を早めに諦めて「sqlp」というライブラリを自分で作りました。 *2
database/sql
をラップしたもので、なるべく近い使い方になるように考慮しました(大きく異なる部分もあります)。
主な特徴は次のとおりです。
- SELECT で取得したデータが構造体/マップ/スライスに入る
- それをシンプルに書ける
- Bulk Insert できる
- プレースホルダの扱いが楽
作ってしばらく寝かせてから GitHub に置いてもう二年近く経つのですが、ずっと Work In Progress 扱いにしています。
WIP の理由
- ドキュメントが不十分
- README はしっかり書きましたが、コードにコメントがないので GoDoc が裸です。
- テストが不十分
- 泥臭いことをしている
- 実用例が少ない
- 個人で時々使っているだけです。
- 自信がない
- 日頃から業務で Go を使っているわけではなく、致命的な不具合がないか不安です。
- 作った本人が不安に思っているものを誰も使うはずがありませんが、万全だと見せかけるのは無責任です。
- DB 関連はミスの影響度が大きいです。
ドキュメント作成やテストは自分でできますが、残りは他者の協力がないと改善が難しそうです。
希望・期待
- 誰かが同じものをより良く作り直してくれないかな
- 便利だと思われた方はぜひオリジナルより優れた模造品を作ってください。
- 使い方は後半に書きます。
- 誰かが改善に協力してくれないかな(Issues、Pull Request)
- 誰かが見かねて大幅にやり直してくれないかな(Fork して新たに)
database/sql
が同じ機能を持っていたらいいのにな
かなり他力本願ですが、こんな気持ちです。
コードの質などは置いておいて、ライブラリのアイデア自体は悪くないと思っています。 よろしければ触ってみてください。
この記事の動機
上記のような期待は持っていても、なかなか記事にまでしようと思い立てませんでした。
しかし、今年の Go の Advent Calendar ではここまで一週間も経っていない間に関連する記事がいくつかあり、自分のアイデアを伝えるのも良いかなと思いました。
Go5 アドカレ 1 日目
Goではどんなライブラリがデータベースにアクセスするときにベストか考える - ぷらすのブログ
クリックで開閉
database/sql ではスキャンしたデータを構造体にマッピングする機能はありません。マッピングするにはスキャンしたデータ一つ一つごとに引数でを渡す必要があります。
実際に使う上では、少々面倒くさいと感じる人が多いと思います。そういった場合はサードパーティのライブラリを使用します。
それでは、サードパーティのライブラリにどのような機能を求めているでしょうか? いくつか考えられるものを挙げてみました。
Go3 アドカレ 2 日目
2019年の GORM 動向 | ザネリは列車を見送った
クリックで開閉
今までGoでの開発を3社経験したが、全社Webアプリケーションフレームワークに Gin、ORM に GORM という構成だった。
スター数や知名度などからメジャーな ORM 感を醸し出しているが、使えば使うほど粗が見えてしまい、
次は別の DB ライブラリを選択したほうがいいのでは?いやそもそも素の database/sql だけで十分なのでは?
いやいやどうせ SELECT した結果を struct にバインドするような処理は必要だから、薄い DB ライブラリは欲しくなるのでは?と考えを巡らせる事がある。
Go4 アドカレ 5 日目
Go 言語で初めてのライブラリ開発 - It's a Piece of Cake
DB 操作のしにくさを感じて便利なライブラリを求めている人が他にもいるんだなと思いました。 また、3 つ目のライブラリ開発の記事ではライブラリを作った経緯などが書かれていて、自分も記事にしようという後押しになりました。
工夫したこと
- refrect
- 未知の構造体型をメソッドで受け取ってフィールドに値を入れるために
reflect
パッケージを使っていますが、扱っているものがポインタなのか何なのかわからなくなることが多かったため、PtrValueOf()
など ラップした関数を用意 して名前でわかるようにしながら使いました。
- 未知の構造体型をメソッドで受け取ってフィールドに値を入れるために
- インタフェース
sqlp.go
というファイルにある sqlExecContext() 等のメソッドは、通常はdatabase.sql
のDB
、トランザクションならTx
、というようにレシーバを変えて共通使用する必要がありました。まさに Structural Subtyping が生きるところだと思い、やってみるとスッキリさせることができました。
- エラー出力
- どこでエラーが起きたかわかりにくかったので、スタックトレースの情報を加工 してファイルパスと行番号を出力するようにしました。(*4)
sqlp の使い方
README がほぼ全てですが、日本語の情報がないのでここにまとめておきます。
DB オープン/クローズ
db, err := sqlp.Open("mysql", "user:pw@tcp(host:3306)/dbname")
db
は *DB
という型で、その構造体の SqlDB
というフィールドに database/sql
の DB
のポインタを持っています。
必要なら取り出して使うことができます。
もし既に *sql.DB
があるならそれを使って Init()
で sqlp の*DB
型に変換できます。
sqlDB, err := sql.Open("mysql", "user:pw@tcp(host:3306)/dbname") db := sqlp.Init(sqlDB)
使い終わったら db.Close()
でクローズしましょう。
INSERT
以下の説明は、次のようなテーブルがあると仮定したものとなります(MySQL の例)。
CREATE TABLE user ( id int(10) unsigned NOT NULL AUTO_INCREMENT, name varchar(32) NOT NULL, age tinyint(3) unsigned NOT NULL, recorded_at datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB;
Insert()
では、挿入するデータを構造体に入れてスライスで渡せるようにしていて、一つでも複数でも可能です。
複数データの場合、自動的に INSERT 文の VALUES の後ろにカンマ区切りで指定する形に変換されます。
また、エスケープも自動です。
type tUser struct { Name string Age int RecordedAt mysql.NullTime `col:"recorded_at"` } now := mysql.NullTime{Time: time.Now(), Valid: true} data := []tUser{ {Name: "User1", Age: 22, RecordedAt: now}, {Name: "User2", Age: 27, RecordedAt: mysql.NullTime{}}, {Name: "User3", Age: 31, RecordedAt: now}, } res, err := db.Insert("user", data) if err != nil { log.Fatal(err) } cnt, _ := res.RowsAffected() id, _ := res.LastInsertId() fmt.Printf("Number of affected rows: %d\n", cnt) // Number of affected rows: 3 fmt.Printf("Last inserted ID: %d\n", id) // Last inserted ID: 1
カラム名は、構造体のフィールド名と case-insensitive な比較によって一致するものが自動的に対応付けられます。
カラム名が recorded_at
、フィールド名が RecordedAt
だと文字の大小を無視しても一致しませんが、タグでカラム名を明示することで解決できます。
3 行を INSERT したのに LastInsertId()
の結果が 3 ではなく 1(まとめて追加したうちの先頭行の ID)になっているのは、おそらくドライバがそうなっているからです(go-sql-driver/mysql の場合)。
*5
*6
UPDATE
ORM ではありませんし、UPDATE の WHERE 句が複雑になることもあって自分で SQL 文を書いたほうがいいと思ったため、Update 専用のメソッドは用意していません。
database/sql
で操作するときと同様に Exec()
等を使うことになります。
Scan
database/sql
の Scan()
は引数としてカラム一つずつのための変数を渡さなければならなくて不便に感じたため、そうしなくても構造体・マップ・スライスのどれかに結果を入れられるようにしました。
下記はマップに入れる例です。
rows, err := db.Query(`SELECT name, age, recorded_at FROM user`) if err != nil { log.Fatal(err) } defer rows.Close() for rows.Next() { u, err := rows.ScanToMap() if err != nil { log.Fatal(err) } fmt.Printf("%s: %s yo [%s]\n", u["name"], u["age"], u["recorded_at"]) }
User1: 22 yo [2018-06-24T01:23:45+00:00] User2: 27 yo [] User3: 31 yo [2018-06-24T01:23:45+00:00]
マップとスライスに入れる場合、カラムが数値等の型であっても文字列になる点に注意が必要です。 interface{} 型にしておいて型アサーションして使うことも考えましたが、文字列で妥協しました(理由は覚えていません)。
構造体の場合、INSERT の場合と違ってアンダースコアの有無によってフィールド名とカラム名が異なっていても同じとみなされます。
また、PrepareContext()
のような database/sql
に存在するメソッドはほとんど用意しているので、SQL 文を先に用意しておいて再利用することや Context でタイムアウト設定やキャンセルをすることもできます。
SELECT
こちらも構造体・マップ・スライスに入れられるようになっています。
Scan()
との大きな違いは、複数行ある場合に Next()
で行ずつ取り出す必要がないことです。
その分少ないコードで済みますが、大量のデータを一度に取得する際にはメモリにご注意ください。
構造体に入れる場合には次のように書けます。
var u []struct { Name string Age int RecordedAt mysql.NullTime } err := db.SelectToStruct(&u, `SELECT name, age, recorded_at FROM user`) if err != nil { log.Fatal(err) } fmt.Printf("%+v\n", u)
[ {Name:User1 Age:22 RecordedAt:{Time:2018-06-24 01:23:45 +0000 UTC Valid:true}} {Name:User2 Age:27 RecordedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} {Name:User3 Age:31 RecordedAt:{Time:2018-06-24 01:23:45 +0000 UTC Valid:true}} ]
マップやスライスでは構造体を用意することなく使えるので、ちょっと取得して確認したいときなどには楽です。
u, err := db.SelectToSlice(`SELECT name, age, recorded_at FROM user`)
名前なしプレースホルダ
プレースホルダは独特のものになっています。
名前なしの場合、DBMS(*7)の実装によらず ?
を使います。
?
以外($1
など)を使わなければならない DBMS に対応する方法は後述します。
q := `UPDATE user SET age = ? WHERE age IN ?[2]`
?[2]
の形式は sqlp 独自です。
この SQL 文は内部的に
UPDATE user SET age = ? WHERE age IN (?,?)
に置換されます。 これに対する値の指定方法は次のいずれも OK です。
db.Exec(q, 11, 22, 33) db.Exec(q, 11, []interface{}{22, 33}) db.Exec(q, []interface{}{11, 22}, 33) db.Exec(q, []interface{}{11, 22, 33}) db.Exec(q, []interface{}{11, 22}, []interface{33})
名前付きプレースホルダ
コロンで始まる名前を使います。
q := `UPDATE user SET age = :age_new WHERE age IN :age[2]` // → UPDATE user SET age = ? WHERE age IN (?,?)
これも先ほどと同じ SQL 文に置換されます。 値の渡し方は名前なしの場合のように自由ではなく、名前(コロンなし)をキーとする interface{} のマップを用います。
res, err := db.Exec(q, map[string]interface{}{ "age_new": 11, "age": []interface{}{22, 33}, })
:age[2]
の形式に対しては interface{} のスライスで指定し、その要素数は []
で指定した数と一致している必要があります。
プレースホルダの記号の変換
?
以外を使うことになっている DBMS では、?
から変換する関数を用意する必要があります。
ただし PostgreSQL の $1
という形式は sqlp であらかじめ対応しています。
placeholder.SetType(placeholder.Dollar) q := "SELECT * FROM user WHERE name LIKE ? AND age IN ?[2]" u, err := db.SelectToMap(q, "User%", 22, 23)
placeholder.SetType()
でドル記号を使うよう指定しているので、次のように変換されます。 *8
SELECT * FROM user WHERE name LIKE $1 AND age IN ($2, $3)
それ以外の記号(そういうものがあるか把握していませんが)も、placeholder.SetConvertFunc()
を使って変換用関数を設定すれば対応できるはずです。
例えば、もし <>
という記号を使わなければならないとしたら、下記のように書けると思います。
placeholder.SetConvertFunc(func(query *string) { cnt := strings.Count(*query, "?") for i := 1; i <= cnt; i++ { *query = strings.Replace(*query, "?", "<>", 1) } })
おわり
どなたか、もっと良いものを作成されたときにはお知らせいただけると嬉しいです。 また、sqlp を改善して使っていけそうならぜひご協力ください!
明日は @kawasin73 さんです。
*1:Bulk Insert に対応していない DBMS のことを考慮したものなのかもしれません(勝手な解釈です)。例えば SQLite は 3.7.11 でようやく対応したようです。 非対応のときだけエラーにすれば良いのでは、と思いました。
*2:名前は database/sql と sqlx へのオマージュです。末尾の「p」は適当で、「plus」か何かその辺りの頭文字です。database/sql より少し使いやすくしたことを意味します。
*3:CRUD 等の動作のテストを書いていないのは、CI で DBMS を用意する方法がわからなかったからです。モックを用意できるようなので、その方法を検討しようと思っています。
*4:見直してみるとそれを使っていない箇所が多かったので要改善です。
*5:database/sql を直に使って Bulk Insert しても同じ結果でした。
*6:RowsAffected() と LastInsertId() のそれぞれの二つ目の戻り値が常に nil になります。これは作りの問題ですので見直そうと思います。
*7:プレースホルダの記号は DBMS というよりもそのドライバによって異なるものでしょうか。
*8:指定しなくてもドライバの種類を読み取って自動判別すると良いかもしれません。