Go から SQL を使う
読了目安 約3分
database/sql で Go プログラムから MySQL を読み書きし、SQL インジェクションを防ぐ書き方を学びます。
この章の目次
database/sql とドライバ
Go では、標準ライブラリの database/sql でクエリを発行します。 database/sql は共通のインターフェースで、実際の通信はドライバという部品が担います。 定番の go-sql-driver/mysql を、作業用ディレクトリを作って取得します。
mkdir go-sql-practice && cd go-sql-practice
go mod init practice
go get github.com/go-sql-driver/mysql接続して複数行を読む
users を全員表示するプログラムです。
DB に接続するのでブラウザでは動かず、/sql/02-select/ の MySQL を起動したまま go run main.go します。
package main
import (
"context"
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, err := sql.Open("mysql", "root:neziki@tcp(127.0.0.1:3306)/neziki?parseTime=true")
if err != nil {
log.Fatal(err)
}
defer db.Close()
ctx := context.Background()
rows, err := db.QueryContext(ctx, "SELECT id, name FROM users")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var id int
var name string
if err := rows.Scan(&id, &name); err != nil {
log.Fatal(err)
}
fmt.Println(id, name)
}
if err := rows.Err(); err != nil {
log.Fatal(err)
}
}1 alice
2 bob
3 carol
4 dave
5 eveコードの要点です。
- import の
_は「パッケージを直接は使わないが、読み込みだけしたい」という書き方。読み込まれたドライバが自分を database/sql に登録する。 sql.Openの第 2 引数は DSN(Data Source Name)と呼ぶ接続情報の文字列。形式はユーザー名:パスワード@tcp(ホスト:ポート)/データベース名。末尾のparseTime=trueは、DATETIME 列を Go の time.Time として受け取るドライバのオプション。- 複数行を返すクエリは
QueryContextで発行する。返ってくるrowsは、結果を先頭から読み進めるカーソル。 rows.Next()で 1 行進めては、rows.Scanに SELECT と同じ列順で書き込み先のポインタを渡して値を写す。ループの後のrows.Err()は、途中で読み取りが失敗していなかったかの確認。defer rows.Close()は忘れると害が大きい。rows は DB との接続を 1 本占有していて、閉じ忘れると接続が漏れ続け、いずれアプリ全体が詰まる。QueryContextが成功したら、次の行で必ず書く。
1 行だけ読む
結果が 1 行のときは QueryRowContext で直接 Scan します。
var name string
err := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = ?", 1).Scan(&name)
if errors.Is(err, sql.ErrNoRows) {
// 該当する行がなかった(エラーではなく「いなかった」として扱うことが多い)
} else if err != nil {
log.Fatal(err)
}行が見つからないと sql.ErrNoRows が返るので、/go/05-error/ の errors.Is で判定します。
書き込む
行を返さない INSERT、UPDATE、DELETE は ExecContext で発行します。
result, err := db.ExecContext(ctx,
"INSERT INTO posts (user_id, body) VALUES (?, ?)", 1, "Go から書き込み")
if err != nil {
log.Fatal(err)
}
id, err := result.LastInsertId()戻り値からは、AUTO_INCREMENT の id(LastInsertId)や影響行数(RowsAffected)が取れます。
文字列連結が招く SQL インジェクション
クエリに値を渡すのに使ってきた ? をプレースホルダと呼びます。
fmt.Sprintf の文字列連結でも同じことができそうに見えます。
// 絶対にやってはいけない書き方
query := fmt.Sprintf("SELECT id FROM users WHERE name = '%s'", name)入力欄から来た name が ' OR '1'='1 だと、組み立て結果はこうなります。
SELECT id FROM users WHERE name = '' OR '1'='1'入力のクォートが構文として解釈され、条件が常に真に変わって全ユーザーが返ります。 入力文字列でクエリの構造を書き換えるこの攻撃を SQL インジェクションと呼びます。
プレースホルダの引数は構文と分離した「ただの値」として MySQL に渡されるので、この攻撃は成立しません。 値は常にプレースホルダで渡す、で統一します。
Scan の手間と sqlx
database/sql の不満は Scan の手作業で、列を足すたびに全部直して回ることになります。
定番ライブラリ sqlx(go get github.com/jmoiron/sqlx)がこの手間を埋めます。
struct のタグに列名を書いておくと、対応を見て自動で詰めてくれます。
type User struct {
ID int `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
CreatedAt time.Time `db:"created_at"`
}
db, err := sqlx.Open("mysql", "root:neziki@tcp(127.0.0.1:3306)/neziki?parseTime=true")
// 複数行は Select で slice に
var users []User
err = db.SelectContext(ctx, &users, "SELECT * FROM users")
// 1 行は Get で
var u User
err = db.GetContext(ctx, &u, "SELECT * FROM users WHERE id = ?", 1)Next のループも rows.Close() も sqlx が中でやります。 ISUCON の初期実装は sqlx が多いので、Get は 1 行、Select は複数行、と読めるようにしておきます。