2020-07-28

gorp の クエリはテンプレートに書いたほうがわかりやすいと思いました。

最近は主に TypeScript を書いてます。楽しいです。

という話は置いておきまして、gorpを使ったバックエンドのコードレビューしていたときに、 SQLがあちこちに散らばってしまい可読性が悪くなってるのを見かけました。

gorpでは生SQLをかけるのでSQLが短ければ直感的にわかりやすいというメリットはありますが、 結合や検索条件が複雑になるに連れコードは関数やファイルで分割されSQLはどんどん散り散りになっていきます。

私は十行足らずのSQLを表現するために数十行の Goのコードを書きたくありませんし、保守できる自信もありません。

前置きが長くなりましたが、表題の通り Go の標準テンプレート機能を使ってSQLを書いたほうが 直感的にわかりやすいだろうと思ったので、期待通りに動作するか検証してみます。

検証環境を作ったので手元で試したい人は Clone してください。 使い方は後述。使うライブラリのバージョンとかは go.mod を参照のこと。 GitHub - righ/gorp-tipsContribute to righ/gorp-tips development by creating an account on GitHub.https://github.com/righ/gorp-tips

準備が面倒で SQLBoiler の記事で使ったデータを流用してるので 設定が雑でも許してください。

👐 準備

$ git clone https://github.com/righ/gorp-tips.git $ cd gorp-tips $ docker-compose up

で環境を起動するとアプリ用のコンテナと MySQL のコンテナが起動するので docker exec -it gorp /bin/bash でアプリのコンテナに入ります。

すでにレコードも入ってるので特にもうすることはないです。一応 MySQL に接続してみます。

root@5a6a76a0bebd:~# mysql -uusr -ppw -hmysql db -e "select * from pilots;" +----+---------+ | id | name | +----+---------+ | 1 | Manfred | | 2 | William | | 3 | Albert | | 4 | Warner | | 5 | Andrew | +----+---------+

大丈夫そうですね。

実行するプログラムは src/ に入ってるので先に移動しておきましょう。

💪 実装

この記事では、上で準備したテーブルを雑にJOIN(LEFT JOIN)して検索して標準出力に表示するプログラムを作っていきます。

今回使ういくつかのプログラムはリポジトリパターンを採用しており、DBをいじる処理は repositories/ 配下に書き、コントローラー経由で呼び出しています。 それぞれのプログラムはコントローラーに異なるリポジトリを指定することで異なる処理を実行するという具合です。

    まぁ難しい話はさておき、重要なポイントはDBアクセスはリポジトリ(repositories/)が担当して、 実行プログラムがそれを使い分けているだけなので以下ではそれらに注目してみていくことにしましょう。(そのために分けたというのもある)

    😇 まずはふつうに

    とりあえず、何も考えず SQLを組み立てて実装してみます。左右がプログラムで下が実行結果です。(以下すべてこの構成)

    root@3e65753c4761:~/src# go run main_without_template.go -language English {JetName:F-8 JetAge:1 JetColor:White PilotName:Albert Language:0xc0001c2260} {JetName:F-7 JetAge:10 JetColor:White PilotName:William Language:0xc0001c2280} {JetName:F-12 JetAge:10 JetColor:White PilotName:William Language:0xc0001c2290} {JetName:F-14 JetAge:10 JetColor:White PilotName:Warner Language:0xc0001c22a0} {JetName:F-17 JetAge:10 JetColor:White PilotName:William Language:0xc0001c22b0} {JetName:F-4 JetAge:20 JetColor:White PilotName:Warner Language:0xc0001c22c0} {JetName:F-18 JetAge:20 JetColor:White PilotName:Albert Language:0xc0001c22d0} {JetName:F-2 JetAge:25 JetColor:White PilotName:William Language:0xc0001c22e0} {JetName:F-3 JetAge:25 JetColor:White PilotName:Albert Language:0xc0001c22f0} {JetName:F-9 JetAge:30 JetColor:White PilotName:Warner Language:0xc0001c2300} {JetName:F-13 JetAge:30 JetColor:White PilotName:Albert Language:0xc0001c2310} {JetName:F-19 JetAge:30 JetColor:White PilotName:Warner Language:0xc0001c2320} root@3e65753c4761:~/src# go run main_without_template.go -language English -pilot_name r {JetName:F-8 JetAge:1 JetColor:White PilotName:Albert Language:0xc0000415d0} {JetName:F-14 JetAge:10 JetColor:White PilotName:Warner Language:0xc0000415f0} {JetName:F-4 JetAge:20 JetColor:White PilotName:Warner Language:0xc000041600} {JetName:F-18 JetAge:20 JetColor:White PilotName:Albert Language:0xc000041610} {JetName:F-3 JetAge:25 JetColor:White PilotName:Albert Language:0xc000041620} {JetName:F-9 JetAge:30 JetColor:White PilotName:Warner Language:0xc000041630} {JetName:F-13 JetAge:30 JetColor:White PilotName:Albert Language:0xc000041640} {JetName:F-19 JetAge:30 JetColor:White PilotName:Warner Language:0xc000041650}
    info
    • Language だけアドレスが表示されているのは language が LEFT OUTER で結合される(つまりnullableな)ので ポインタ型にする必要があったのです。構造体内のポインタ型を Printf でうまく表示する方法がみつからなかったのでこのようになってます。
    • 大文字から始まる JetRepository は構造体ではなくインタフェースです。 リポジトリ初期化関数の返却値やコントローラーの仮引数の型として使うことで抽象化しています。

    標準入力で受け取った値を makeCondition で組み立て、WHERE条件に渡すことでフィルタリングしています。 検索に必要なフィールドは固定でJOINしていますがパフォーマンスを考慮するなら JOIN の組み立ても分岐するべきでしょう。

    今回の要件はシンプルなのでそこまで複雑ではないですがすでに少し辛さを感じます。検索条件を組み立てるSQLが分かれてしまっているのと、 文字列結合時に前後のスペースを意識しないといけないのが地味に精神に来ます。

    📖 テンプレートに書いてみる

    ということで実行するSQLを テンプレートファイルに切り出してみます。

    まず SQL を書いたGoテンプレートがこちら。

    パラメータの有無によってかき分けたいところは {{}} で囲んで条件分岐しています。 Go テンプレートの記法は Package template とかを見ましょう

    続いてこのSQLを読み出して描画する関数 GetSQL を以下のように定義してみます。

    見てわかるようにテンプレートを読み出して描画したものをバッファに突っ込んでいるだけです。

    この関数を用いて先程と同様のプログラムを書いてみますね。ほぼリポジトリにしか違いがないので右側にだけ注目してもらえればよいです。

    root@3e65753c4761:~/src# go run main_with_template.go -language English {JetName:F-8 JetAge:1 JetColor:White PilotName:Albert Language:0xc000099780} {JetName:F-7 JetAge:10 JetColor:White PilotName:William Language:0xc0000997a0} {JetName:F-12 JetAge:10 JetColor:White PilotName:William Language:0xc0000997b0} {JetName:F-14 JetAge:10 JetColor:White PilotName:Warner Language:0xc0000997c0} {JetName:F-17 JetAge:10 JetColor:White PilotName:William Language:0xc0000997d0} {JetName:F-4 JetAge:20 JetColor:White PilotName:Warner Language:0xc0000997e0} {JetName:F-18 JetAge:20 JetColor:White PilotName:Albert Language:0xc0000997f0} {JetName:F-2 JetAge:25 JetColor:White PilotName:William Language:0xc000099800} {JetName:F-3 JetAge:25 JetColor:White PilotName:Albert Language:0xc000099810} {JetName:F-9 JetAge:30 JetColor:White PilotName:Warner Language:0xc000099820} {JetName:F-13 JetAge:30 JetColor:White PilotName:Albert Language:0xc000099830} {JetName:F-19 JetAge:30 JetColor:White PilotName:Warner Language:0xc000099840} root@3e65753c4761:~/src# go run main_with_template.go -language English -pilot_name r {JetName:F-8 JetAge:1 JetColor:White PilotName:Albert Language:0xc000099780} {JetName:F-14 JetAge:10 JetColor:White PilotName:Warner Language:0xc0000997a0} {JetName:F-4 JetAge:20 JetColor:White PilotName:Warner Language:0xc0000997b0} {JetName:F-18 JetAge:20 JetColor:White PilotName:Albert Language:0xc0000997c0} {JetName:F-3 JetAge:25 JetColor:White PilotName:Albert Language:0xc0000997d0} {JetName:F-9 JetAge:30 JetColor:White PilotName:Warner Language:0xc0000997e0} {JetName:F-13 JetAge:30 JetColor:White PilotName:Albert Language:0xc0000997f0} {JetName:F-19 JetAge:30 JetColor:White PilotName:Warner Language:0xc000099800}

    プログラムの最初の方は共通なので同じで、重要なのは後半です。 ファイル名(パス)と検索パラメータの構造体だけを渡してあげればSQLが取得できるのでとてもシンプルに見えませんか?

    その後、取得した SQL中の 変数 を 展開するためのコンテキストをmapで渡してあげています。 渡した変数の使用可否を決めるのはテンプレートなのでこちらは何も考えずに検索条件を組み立ててあげればよいのです。

    🐍 テンプレートファイルをバイナリに潜影蛇手するわね

    アプリを動かす環境がコンテナの場合 実行するSQLファイルもイメージに乗せてあげないといけません。

    docker add などで追加するのもいいんですが、今回は statik というプログラムを使います。

    これは静的ファイルをパッケージに埋め込むことで go のプログラムから ファイルへのアクセスを擬似的に表現します。

    statikの具体的な用途はDockerイメージ内でファイルを参照するとか、シングルバイナリにアセットを含めて配布するような場合です。

    実際にファイルをバイナリ(プログラム)に埋め込むには go get github.com/rakyll/statik のように statik コマンドをインストールする必要があります。 (ビルドだけなら不要)

    プログラム中に書かれている //go:generate statik -f -src=../files -m というコメントは ../files 内のファイルを statik パッケージ(正確には statik.go)に閉じ込めるアノテーションのようなもので、 go generate することで実行されます。(ディレクトリに再帰的に適用する場合は go generate ./... のようにすること)

    自動的に同期されるわけではないのでファイルが更新されたら statik の実行も忘れないようにしましょう。

    初回実行時には statik が空で go のパッケージとして認識されないので、私の環境では dummy.go というファイルを置いています。

    使うSQLファイルは先ほどと同じなので説明を省略して、SQLを読み出すための関数を新設します。 雑に GetSQL2 と命名しました。

    この関数を用いて先程と同様のプログラムを書いてみますよ。

    root@3e65753c4761:~/src# go run main_with_template_in_bin.go -language English {JetName:F-8 JetAge:1 JetColor:White PilotName:Albert Language:0xc000040200} {JetName:F-7 JetAge:10 JetColor:White PilotName:William Language:0xc000040220} {JetName:F-12 JetAge:10 JetColor:White PilotName:William Language:0xc000040230} {JetName:F-14 JetAge:10 JetColor:White PilotName:Warner Language:0xc000040240} {JetName:F-17 JetAge:10 JetColor:White PilotName:William Language:0xc000040250} {JetName:F-4 JetAge:20 JetColor:White PilotName:Warner Language:0xc000040260} {JetName:F-18 JetAge:20 JetColor:White PilotName:Albert Language:0xc000040270} {JetName:F-2 JetAge:25 JetColor:White PilotName:William Language:0xc000040280} {JetName:F-3 JetAge:25 JetColor:White PilotName:Albert Language:0xc000040290} {JetName:F-9 JetAge:30 JetColor:White PilotName:Warner Language:0xc0000402a0} {JetName:F-13 JetAge:30 JetColor:White PilotName:Albert Language:0xc0000402b0} {JetName:F-19 JetAge:30 JetColor:White PilotName:Warner Language:0xc0000402c0} root@3e65753c4761:~/src# go run main_with_template_in_bin.go -language English -pilot_name r {JetName:F-8 JetAge:1 JetColor:White PilotName:Albert Language:0xc00018e1d0} {JetName:F-14 JetAge:10 JetColor:White PilotName:Warner Language:0xc00018e1f0} {JetName:F-4 JetAge:20 JetColor:White PilotName:Warner Language:0xc00018e200} {JetName:F-18 JetAge:20 JetColor:White PilotName:Albert Language:0xc00018e210} {JetName:F-3 JetAge:25 JetColor:White PilotName:Albert Language:0xc00018e220} {JetName:F-9 JetAge:30 JetColor:White PilotName:Warner Language:0xc00018e230} {JetName:F-13 JetAge:30 JetColor:White PilotName:Albert Language:0xc00018e240} {JetName:F-19 JetAge:30 JetColor:White PilotName:Warner Language:0xc00018e250}

    動いていそうなのでこれ以上言うことはないです。

    👺 注意点

    テンプレートの変数とSQL自体の変数で2段階の展開をしているので「なぜこんな面倒な使い分けをしてるの?」と思うかもしれませんが、 可能な限り SQLの変数(以降プレースホルダ)を使うようにしたいのです。

    GoのテンプレートはSQLを理解しないので、テンプレートによって変数を出力すると 不正なSQL断片を受け入れてSQLインジェクション攻撃に対して脆弱になります。

    ユーザからの入力値を描画する場合はプレースホルダを使いましょう。ぉじさんとの約束だょ。

    • OK
    • NG
    • -- 前略 AND jets.age = :age -- 後略
    • -- 前略 AND jets.age = {{.Age}} -- 後略

    テンプレートにSQLを書くためのサードパーティライブラリもあるようですが、標準テンプレートだけで大した手間もなく実装できました。

    もしSQLの組み立てに苦しんでいる方はぜひ試してみてください。

    こんなん常識やろとか、もっといい方法があるぞという方はコメントお願いします!