のんびり精進

調べた情報などをおすそ分けできれば。

こんなデータベース用ライブラリを誰か作ってほしい(Go)

Go2 Advent Calendar 2019 の 6 日目の記事です。


Go の database/sql って使いにくくないでしょうか。 二年ちょっと前にもっと楽にできないかなと思って調べました。

欲しかったもの

  • database/sql を使いやすくしたもの
  • ORM は要らない
  • ただし、SELECT と INSERT は楽をしたい

ORM を必須とする人もいると思いますが、そのときは直に SQL 文を書きたかったので、それに合ったライブラリだけを試しました。

試したライブラリ

  • sqlx
    • Connect()MustExec() など database/sql とのメソッド名の違いが大きくて、感覚的に避けてしまいました。
  • gorp
    • 割と近い感じで使えて好感触でした。

というわけで gorp を選び、そのときに書いた記事が下記二つです。

gorp の問題点

しかし、大きな問題を見つけました。 なんと gorp は Bulk Insert に対応していなかったのです。(´・ω・`)

一度に INSERT したいデータをスライスに入れて渡すと一応できたのですが、大量のデータだと非常に遅くなってしまいました。 うろ覚えですが、gorp のコードを見てみると 1 データずつ INSERT しているようでした。 *1

自分で改良して貢献するチャンスだと思ったのですが、力不足で無理でした。 gorp のユーザはどうしているのでしょうか。


@mattn さんがつい最近解決されていたことに先ほど気づきました。 話が止まってしまっているように見えますが、gorp に取り込まれるといいなと思います。

Multiple insert · Issue #313 · go-gorp/gorp · GitHub

自分で作りました

タイトルで「誰か作ってほしい」としましたが、実は gorp の問題を早めに諦めて「sqlp」というライブラリを自分で作りました。 *2

github.com

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 ではスキャンしたデータを構造体にマッピングする機能はありません。マッピングするにはスキャンしたデータ一つ一つごとに引数でを渡す必要があります。

実際に使う上では、少々面倒くさいと感じる人が多いと思います。そういった場合はサードパーティのライブラリを使用します。

それでは、サードパーティのライブラリにどのような機能を求めているでしょうか? いくつか考えられるものを挙げてみました。

  • 構造体へのマッピング
  • 学習コストが低い
  • 素のSQLを書きたい or 書きたくない

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.sqlDBトランザクションなら Tx 、というようにレシーバを変えて共通使用する必要がありました。まさに Structural Subtyping が生きるところだと思い、やってみるとスッキリさせることができました。
  • エラー出力

sqlp の使い方

README がほぼ全てですが、日本語の情報がないのでここにまとめておきます。

DB オープン/クローズ

db, err := sqlp.Open("mysql", "user:pw@tcp(host:3306)/dbname")

db*DB という型で、その構造体の SqlDB というフィールドに database/sqlDB のポインタを持っています。 必要なら取り出して使うことができます。

もし既に *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/sqlScan() は引数としてカラム一つずつのための変数を渡さなければならなくて不便に感じたため、そうしなくても構造体・マップ・スライスのどれかに結果を入れられるようにしました。 下記はマップに入れる例です。

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 のことを考慮したものなのかもしれません(勝手な解釈です)。例えば SQLite3.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:指定しなくてもドライバの種類を読み取って自動判別すると良いかもしれません。