🔩 ねじき教室 Go と Web の教室

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 します。

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 します。

Go
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 で発行します。

Go
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 の文字列連結でも同じことができそうに見えます。

Go
// 絶対にやってはいけない書き方
query := fmt.Sprintf("SELECT id FROM users WHERE name = '%s'", name)

入力欄から来た name が ' OR '1'='1 だと、組み立て結果はこうなります。

SQL
SELECT id FROM users WHERE name = '' OR '1'='1'

入力のクォートが構文として解釈され、条件が常に真に変わって全ユーザーが返ります。 入力文字列でクエリの構造を書き換えるこの攻撃を SQL インジェクションと呼びます。

プレースホルダの引数は構文と分離した「ただの値」として MySQL に渡されるので、この攻撃は成立しません。 値は常にプレースホルダで渡す、で統一します。

Scan の手間と sqlx

database/sql の不満は Scan の手作業で、列を足すたびに全部直して回ることになります。 定番ライブラリ sqlxgo get github.com/jmoiron/sqlx)がこの手間を埋めます。 struct のタグに列名を書いておくと、対応を見て自動で詰めてくれます。

Go
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 は複数行、と読めるようにしておきます。