exql v2をリリースしました
会社で使っているGoのMySQL ORMであるexqlのv2をリリースしたkeroxp.icon2023/02/07 初版をリリースしたのは2020年の5月なので、ほぼ3年ぶりのメジャーアップデートになる
機能の解説は割愛するが、今回のメジャーアップデートで意識したことを書いておく
v1時代
この間、v1はほそぼそとアップデートを繰り返して来たが、機能追加はしてこなかった
v1に当時の僕たち(ロイロ開発チーム)が必要としていた機能は全て詰め込んだつもりだったし、世の機能過多の割に融通の効かないORMを使うのも嫌だったので、ある程度の不便は甘受しつつもなんとか開発を進めていた
僕はv1をリリースしてからクライアントアプリに注力していたので、ここ2年位はGoのサーバーはあまり見てこなかった
しかし久しぶりにサーバーチームの開発に参加して、結構DRYというか、冗長なコードがたくさん書かれている光景を見にしてなんとかしないといかんという気持ちが湧いてきた
当初方針
v2を作るという一番の動機は、SELECTクエリの動的生成をより簡単に出来ないか、ということだった
exqlは、INSERT/UPDATEクエリに関しては、構造体から自動的に組み立てることをやっている
しかしSELECTクエリの生成に関しては一切ノータッチを決め込んでいた
これは、サービスの開発においてSELECTクエリは最も書かれるSQLステートメントでありながら、決まりきったパターンが見つけにくい部分だからだ
code:sql
select * from users where id = 1;
SQLの入門本にはだいたいこのようなコードが載っている
世の中のORMは大体こういうSQLをこんなプログラムに落とし込む
code:sql
User.findById(1);
User.find({id: 1})
まあ分かる。users というテーブルがUserというクラスに紐付いて、selectクエリとその条件句であるwhereがfindというメソッドに対応すると、そういうあれだ。
だが、このようなAPIでできることは、このようにselect文の最も単純な表現でしかない
SQLでできることはあまりにも多く、SQLでできることを汎用プログラムのインターフェースで完全再現することは不可能である
SQLでできることを最も完璧に実現できるのはSQLだけであり、それはどんな簡単なクエリでも同じことである
プログラムがSQL文の生成を抽象化するとどういう事が起きるか?
というわけで、どんな小さいクエリでも、できる限りSQLで書くべきというのが、僕たちの意見である
ただそうは言っても、どうしても自分たちで書いてられないSQLの部分というのも存在する
その最も最たる部分が、insertである
insert文はだいたいこんな感じのSQLである
code:sql
insert into users (name,age) values ("go", 16)
テーブルに対して、カラムの名前と値をセットで渡すクエリだ
だがこういうクエリはこの程度ならまだいいが、not nullなカラムが10個とか20個とかになると、途端に人知を超える
ていうかまず一行で読めなくなる
なので、ORMはプログラムで扱うデータ単位(オブジェクトとか構造体)からKeyValueのペアを取り出して、上記のようなクエリを自動的に生成するわけである
動的SQL生成の危険性
動的にSQLを生成することは潜在的に危険な行為である
というのは、ORMというのは基本的に自動的に生成したSQLをその内容を加味せずに実行するので、たまにプログラマが意図しない変なクエリになってしまうことが横行する
code:go
var metadata []*model.Metadata
err := db.Delete(metadata).Err
僕たちが一瞬使っていた某ORMは、こんなプログラムが、
code:sql
delete from metadata
というSQLを生成していた。ということに結構長く気が付かなかった。
(勿論、テストでは空配列を渡すことなどしてなかった)
ので、安全性が十分に保証されていると判断した部分を除いてはSQLの動的生成は避けるべきという思想を持っている
SQLプレースホルダ
Goの公式SQLライブラリには、SQLインジェクションを防ぐためのプレースホルダ機能が備わっている
code:go
db.Query(select * from users where id = ?, 1)
このように、SQLの中で『値』に相当する部分を?に置き換えて実行してくれる
これはDBのドライバの実装にもよるが、大抵はPrepared Statementと呼ばれる機能で実行される
これを使わないと、コードはこの様に生成することになる
code:go
db.Query(select * from users where id = + id)
これが悪名高いSQLインジェクションの現場である
言語の実装にもよるが、もしidの部分に意図した形式ではなく、任意の文字列を埋め込まれた場合、それは任意のSQLを実行されてしまうということを意味する
プレースホルダ機能(Prepared Statement)は、SQLの動的生成をせず、動的な値を別途管理することによって任意のSQLを生成されることを防ぐ大事な仕組みなのである
GoのMySQLドライバーなどは上記のようなコードを以下のようなSQLに変換して実行している
code:sql
PREPARE stmt FROM "select from users where id = ?";
SET @id = 1;
EXECUTE stmt USING @id;
DEALLOCATE PREPARE stmt;
プレースホルダを動的に生成しなければならない場合
上記のように渡す値の数が予め分かっている場合は問題ないが、ときにそうではない場合がある
その典型的な例がin句である
code:sql
select * from users where id in (?,?)
このようにwhere句に複数のキーを設定する場合、値のプレースホルダはその数になる
だが、それが配列になっていた場合、この様に静的にSQLを記述することはできなくなる
Goだとこの様に書くことになる
code:go
ids := []any{1,2,3}
placeholders := "?,?,?" // depends on ids size
q := fmt.Sprintf(SELECT * FROM users WHERE id IN (%s) AND age = ?, placeholders)
var args []any
args = append(args, ids...)
args = append(args, 20)
rows, err := db.DB().Query(q, args...)
面倒である
この典型的な動的なクエリ生成のコードには二つの問題点がある
1つ目は、fmt.Sprintfを使わざるを得ないという点
任意の長さの配列をプレースホルダにする場合、どうやっても動的にクエリを組み立てることは必要なのだが、それにしてもfmt.Sprintfは安易だし、危険な選択肢である
2つ目は、そもそもGoのSQLライブラリの問題点でもあるが、動的な値はいちいちany型の配列を宣言して追加しないといけないところである。(Goのany型の可変長引数(...any)は任意の型の配列のスプレッド展開を許容してくれない、WHY?)
v2ではどう書くか?
code:go
ids := []int{1,2,3}
q := query.New(
SELECT * FROM users WHERE id IN (:?) AND age = ?, query.V(ids...), 20,
)
// SELECT * FROM users WHERE id IN (?,?,?) AND age = ?
rows, err := db.Query(q)
v2ではこれらの問題点をquery.Queryインターフェースを導入することで解決した
code:go
type Query interface {
Query() (string, []any, error)
}
Queryは動的なSQLクエリとプレースホルダと値を抽象化した再利用可能なコンポーネントである
例えば、上記のquery.V()は以下のようなコンポーネントを管理している
code:go
str, args, _ := query.V(1,2,3).Query()
// str => "?,?,?"
// args => []any{1,2,3}
query.Newはquery.Queryを生成するメソッドだが、ここに一つだけ独自の構文が存在する
:?はexql独自のプレースホルダであり、これは残りの引数のquery.Vと対応している
exqlはSQL文中の:?をプレースホルダと扱い、それに対応する位置の引数がquery.Queryだった場合のみ、その部分にQuery()メソッドの返り値を展開して埋め込み、引数は順番にバッファに追加する
?の部分に対応する位置の引数はそのままバッファに追加する
そのようにすることで、
fmt.Sprintfによる動的な文字列の生成
[]anyの配列の管理
の二つを解決した