データベース操作
レッスン27:データベース操作
現実世界のアナロジー
図書館を経営していると想像してください —
- データベースは図書館の倉庫で、すべての本(データ)が棚にきちんと整理されています。
database/sqlインターフェースは司書のマニュアルのようなもので、「本を棚に置く方法、本を見つける方法、本を貸し出す方法」の標準手順を定義しています。倉庫が木製の棚(SQLite)でも鋼鉄の棚(MySQL)でも、マニュアルは普遍的に機能します。- ドライバは異なる倉庫のための特定の鍵です。SQLiteの鍵はローカルの小さな倉庫を開き、MySQLの鍵はリモートの大きな倉庫を開きます。
- プリペアドステートメントは事前に印刷された貸出フォームのようなもので、毎回名前と本のタイトルを記入するだけで、高速で安全です。
- トランザクションはアトミックな「貸し出しと返却」操作のようなもので、すべて完了するかすべてロールバックされ、「貸したが記録されていない」という事態を回避します。
このレッスンの後、Goを使用してデータベースを自信を持って操作できるようになります。
コアコンセプト
| コンセプト | 説明 |
|---|---|
database/sql |
Go標準ライブラリ、統一されたデータベース操作インターフェースを定義 |
| ドライバ | database/sql/driverインターフェースを実装するサードパーティパッケージ |
sql.Open() |
データベース接続プールを作成(すぐに接続を確立するわけではない) |
sql.DB |
データベース接続プールオブジェクト、スレッドセーフ |
Query / QueryRow |
クエリステートメントを実行し、結果セットを返す |
Exec |
非クエリステートメントを実行(INSERT/UPDATE/DELETE) |
Prepare |
プリペアドステートメント、繰り返し実行のパフォーマンスを向上 |
Tx |
トランザクションオブジェクト、CommitとRollbackをサポート |
| 接続プール | 接続を再利用し、頻繁な接続のオープン/クローズのオーバーヘッドを回避 |
基本構文と使い方
1. ドライバのインポート
package main
import (
"database/sql"
_ "github.com/mattn/go-sqlite3" // SQLiteドライバ(匿名インポート、ドライバのみ登録)
)
_で匿名インポートする必要があります。database/sqlにドライバを登録するためにinit()関数を実行するだけで、ドライバ関数を直接呼び出すことはないためです。
2. データベースを開く
db, err := sql.Open("sqlite3", "./mydb.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 接続が利用可能か確認
if err = db.Ping(); err != nil {
log.Fatal("データベースに接続できません:", err)
}
sql.Open()はすぐに接続を確立するわけではなく、接続プールの設定を初期化するだけです。実際に接続を試みるにはPing()を使用してください。
3. テーブルの作成
createTableSQL := `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
age INTEGER DEFAULT 0
);`
_, err = db.Exec(createTableSQL)
if err != nil {
log.Fatal("テーブルの作成に失敗しました:", err)
}
4. CRUD操作
// ---- INSERT ----
result, err := db.Exec("INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
"Alice", "alice@example.com", 25)
if err != nil {
log.Fatal(err)
}
id, _ := result.LastInsertId() // 自動インクリメントIDを取得
fmt.Println("正常に挿入、ID:", id)
// ---- SELECT ----
var name, email string
var age int
err = db.QueryRow("SELECT name, email, age FROM users WHERE id = ?", id).
Scan(&name, &email, &age)
if err != nil {
log.Fatal(err)
}
fmt.Printf("クエリ結果: %s、%s、%d歳\n", name, email, age)
// ---- UPDATE ----
_, err = db.Exec("UPDATE users SET age = ? WHERE id = ?", 26, id)
if err != nil {
log.Fatal(err)
}
// ---- DELETE ----
_, err = db.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
log.Fatal(err)
}
5. プリペアドステートメント
stmt, err := db.Prepare("INSERT INTO users (name, email, age) VALUES (?, ?, ?)")
if err != nil {
log.Fatal(err)
}
defer stmt.Close()
// 同じstmtを複数回再利用でき、パフォーマンスが向上する
stmt.Exec("Bob", "bob@example.com", 30)
stmt.Exec("Charlie", "charlie@example.com", 28)
6. トランザクション処理
tx, err := db.Begin()
if err != nil {
log.Fatal(err)
}
// トランザクション内で複数のステートメントを実行
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", 100, 1)
if err != nil {
tx.Rollback() // エラー時にロールバック
log.Fatal(err)
}
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", 100, 2)
if err != nil {
tx.Rollback()
log.Fatal(err)
}
if err = tx.Commit(); err != nil { // 成功時にコミット
log.Fatal(err)
}
fmt.Println("トランザクションが正常にコミットされました")
7. 接続プール設定
db.SetMaxOpenConns(25) // 最大オープン接続数
db.SetMaxIdleConns(10) // 最大アイドル接続数
db.SetConnMaxLifetime(5 * time.Minute) // 接続の最大存続期間
db.SetConnMaxIdleTime(3 * time.Minute) // アイドル接続の最大存続期間
MaxOpenConnsを低く設定するとリクエストがキューイングされ、高く設定しすぎるとデータベースリソースが枯渇します。
実践
例:SQLiteユーザー管理(難易度⭐)
基本的なCRUD操作を実演する完全なユーザー管理の例です。
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/mattn/go-sqlite3" // SQLiteドライバ
)
// User構造体
type User struct {
ID int
Name string
Email string
Age int
}
func main() {
// SQLiteデータベースを開く(または作成する)
db, err := sql.Open("sqlite3", "./users.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// テーブルを作成
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
age INTEGER DEFAULT 0
)
`)
if err != nil {
log.Fatal("テーブルの作成に失敗しました:", err)
}
// ユーザーを挿入
result, err := db.Exec(
"INSERT INTO users (name, email, age) VALUES (?, ?, ?)",
"Alice", "alice@example.com", 25,
)
if err != nil {
log.Fatal("挿入に失敗しました:", err)
}
id, _ := result.LastInsertId()
fmt.Printf("ユーザーが正常に挿入されました、ID: %d\n", id)
// 単一ユーザーをクエリ
var u User
err = db.QueryRow("SELECT id, name, email, age FROM users WHERE id = ?", id).
Scan(&u.ID, &u.Name, &u.Email, &u.Age)
if err != nil {
log.Fatal("クエリに失敗しました:", err)
}
fmt.Printf("クエリ結果: %+v\n", u)
// ユーザーの年齢を更新
_, err = db.Exec("UPDATE users SET age = ? WHERE id = ?", 26, id)
if err != nil {
log.Fatal("更新に失敗しました:", err)
}
fmt.Println("正常に更新されました")
// すべてのユーザーをクエリ
rows, err := db.Query("SELECT id, name, email, age FROM users")
if err != nil {
log.Fatal("全件クエリに失敗しました:", err)
}
defer rows.Close()
fmt.Println("\nすべてのユーザー:")
for rows.Next() {
var user User
if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.Age); err != nil {
log.Fatal(err)
}
fmt.Printf(" ID:%d 名前:%s メール:%s 年齢:%d\n",
user.ID, user.Name, user.Email, user.Age)
}
// ユーザーを削除
_, err = db.Exec("DELETE FROM users WHERE id = ?", id)
if err != nil {
log.Fatal("削除に失敗しました:", err)
}
fmt.Println("ユーザーが正常に削除されました")
}
ユーザーが正常に挿入されました、ID: 1
クエリ結果: {ID:1 Name:Alice Email:alice@example.com Age:25}
正常に更新されました
すべてのユーザー:
ID:1 名前:Alice メール:alice@example.com 年齢:26
ユーザーが正常に削除されました
例:CRUDカプセル化 + プリペアドステートメント(難易度⭐⭐)
データベース操作を構造体メソッドとしてカプセル化し、プリペアドステートメントを使用してパフォーマンスを向上させます。
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/mattn/go-sqlite3"
)
// Userモデル
type User struct {
ID int
Name string
Email string
Age int
}
// UserDAOはユーザーデータアクセスオブジェクト
type UserDAO struct {
db *sql.DB
insertStmt *sql.Stmt
getByID *sql.Stmt
updateStmt *sql.Stmt
deleteStmt *sql.Stmt
}
// NewUserDAOはDAOを作成し、プリペアドステートメントを初期化する
func NewUserDAO(db *sql.DB) (*UserDAO, error) {
dao := &UserDAO{db: db}
// テーブルを初期化
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
age INTEGER DEFAULT 0
)
`)
if err != nil {
return nil, err
}
// すべての一般的に使用されるステートメントを準備
dao.insertStmt, err = db.Prepare(
"INSERT INTO users (name, email, age) VALUES (?, ?, ?)")
if err != nil {
return nil, err
}
dao.getByID, err = db.Prepare(
"SELECT id, name, email, age FROM users WHERE id = ?")
if err != nil {
return nil, err
}
dao.updateStmt, err = db.Prepare(
"UPDATE users SET name = ?, email = ?, age = ? WHERE id = ?")
if err != nil {
return nil, err
}
dao.deleteStmt, err = db.Prepare(
"DELETE FROM users WHERE id = ?")
if err != nil {
return nil, err
}
return dao, nil
}
// Closeはすべてのプリペアドステートメントを閉じる
func (d *UserDAO) Close() {
d.insertStmt.Close()
d.getByID.Close()
d.updateStmt.Close()
d.deleteStmt.Close()
}
// Createは新しいユーザーを挿入する
func (d *UserDAO) Create(u *User) error {
result, err := d.insertStmt.Exec(u.Name, u.Email, u.Age)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
u.ID = int(id)
return nil
}
// GetByIDはIDでユーザーをクエリする
func (d *UserDAO) GetByID(id int) (*User, error) {
u := &User{}
err := d.getByID.QueryRow(id).Scan(&u.ID, &u.Name, &u.Email, &u.Age)
if err != nil {
return nil, err
}
return u, nil
}
// Updateはユーザー情報を更新する
func (d *UserDAO) Update(u *User) error {
_, err := d.updateStmt.Exec(u.Name, u.Email, u.Age, u.ID)
return err
}
// Deleteはユーザーを削除する
func (d *UserDAO) Delete(id int) error {
_, err := d.deleteStmt.Exec(id)
return err
}
// ListAllはすべてのユーザーをクエリする
func (d *UserDAO) ListAll() ([]User, error) {
rows, err := d.db.Query("SELECT id, name, email, age FROM users")
if err != nil {
return nil, err
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.Age); err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
func main() {
db, err := sql.Open("sqlite3", ":memory:") // デモンストレーション用インメモリデータベース
if err != nil {
log.Fatal(err)
}
defer db.Close()
dao, err := NewUserDAO(db)
if err != nil {
log.Fatal(err)
}
defer dao.Close()
// バッチ挿入
users := []User{
{Name: "Alice", Email: "alice@example.com", Age: 25},
{Name: "Bob", Email: "bob@example.com", Age: 30},
{Name: "Charlie", Email: "charlie@example.com", Age: 28},
}
for i := range users {
if err := dao.Create(&users[i]); err != nil {
log.Printf("挿入に失敗しました: %v", err)
continue
}
fmt.Printf("挿入しました: %s (ID=%d)\n", users[i].Name, users[i].ID)
}
// クエリと変更
u, err := dao.GetByID(1)
if err != nil {
log.Fatal("クエリに失敗しました:", err)
}
fmt.Printf("\nID=1をクエリ: %+v\n", u)
u.Age = 26
if err := dao.Update(u); err != nil {
log.Fatal("更新に失敗しました:", err)
}
fmt.Printf("更新後: %+v\n", u)
// すべて一覧表示
all, _ := dao.ListAll()
fmt.Println("\nすべてのユーザー:")
for _, user := range all {
fmt.Printf(" [%d] %s | %s | %d歳\n",
user.ID, user.Name, user.Email, user.Age)
}
// 削除
dao.Delete(2)
fmt.Println("\nID=2を削除後:")
all, _ = dao.ListAll()
for _, user := range all {
fmt.Printf(" [%d] %s | %s | %d歳\n",
user.ID, user.Name, user.Email, user.Age)
}
}
挿入しました: Alice (ID=1)
挿入しました: Bob (ID=2)
挿入しました: Charlie (ID=3)
ID=1をクエリ: {ID:1 Name:Alice Email:alice@example.com Age:25}
更新後: {ID:1 Name:Alice Email:alice@example.com Age:26}
すべてのユーザー:
[1] Alice | alice@example.com | 26歳
[2] Bob | bob@example.com | 30歳
[3] Charlie | charlie@example.com | 28歳
ID=2を削除後:
[1] Alice | alice@example.com | 26歳
[3] Charlie | charlie@example.com | 28歳
例:トランザクション送金 + 接続プール設定(難易度⭐⭐⭐)
銀行送金シナリオをシミュレートし、トランザクションのアトミック性保証と接続プール設定を実演します。
package main
import (
"database/sql"
"fmt"
"log"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Accountは銀行口座
type Account struct {
ID int
Name string
Balance float64
}
func initDB(db *sql.DB) error {
// テーブルを作成
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
balance REAL NOT NULL DEFAULT 0
)
`)
return err
}
// Transferは送金を実行する(トランザクション操作)
// fromIDからtoIDへ金額を送金する
func Transfer(db *sql.DB, fromID, toID int, amount float64) error {
// トランザクションを開始
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("トランザクションの開始に失敗しました: %w", err)
}
// トランザクションがコミットまたはロールバックされることを保証
defer func() {
if err != nil {
tx.Rollback()
}
}()
// 送金元口座の残高を確認
var fromBalance float64
err = tx.QueryRow("SELECT balance FROM accounts WHERE id = ?", fromID).Scan(&fromBalance)
if err != nil {
return fmt.Errorf("送金元口座のクエリに失敗しました: %w", err)
}
if fromBalance < amount {
return fmt.Errorf("残高不足: 現在 %.2f、必要 %.2f", fromBalance, amount)
}
// 送金元から引き落とし
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
if err != nil {
return fmt.Errorf("引き落としに失敗しました: %w", err)
}
// 送金先に入金
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
if err != nil {
return fmt.Errorf("入金に失敗しました: %w", err)
}
// ビジネスロジックをシミュレート:操作ログを記録(同じトランザクション内で)
_, err = tx.Exec(
"INSERT INTO transfer_log (from_id, to_id, amount, created_at) VALUES (?, ?, ?, ?)",
fromID, toID, amount, time.Now().Format(time.RFC3339),
)
if err != nil {
// ログテーブルが存在しない場合、エラーを無視(デモンストレーションのみ)
fmt.Printf(" [警告] 送金ログの記録に失敗しました: %v\n", err)
}
// トランザクションをコミット
if err = tx.Commit(); err != nil {
return fmt.Errorf("トランザクションのコミットに失敗しました: %w", err)
}
return nil
}
// PrintBalancesはすべての口座残高を表示する
func PrintBalances(db *sql.DB) {
rows, err := db.Query("SELECT id, name, balance FROM accounts ORDER BY id")
if err != nil {
log.Fatal(err)
}
defer rows.Close()
fmt.Println(" 口座残高:")
for rows.Next() {
var a Account
if err := rows.Scan(&a.ID, &a.Name, &a.Balance); err != nil {
log.Fatal(err)
}
fmt.Printf(" [%d] %-6s 残高: %.2f\n", a.ID, a.Name, a.Balance)
}
}
func main() {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 接続プールを設定
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(3 * time.Minute)
fmt.Println("✓ 接続プールが設定されました:")
fmt.Println(" 最大接続数: 10")
fmt.Println(" 最大アイドル数: 5")
fmt.Println(" 接続存続期間: 5分")
fmt.Println()
// テーブルを初期化
if err := initDB(db); err != nil {
log.Fatal(err)
}
// 送金ログテーブルを作成(オプション)
db.Exec(`CREATE TABLE IF NOT EXISTS transfer_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_id INTEGER, to_id INTEGER,
amount REAL, created_at TEXT
)`)
// 初期口座を挿入
_, err = db.Exec("INSERT INTO accounts (name, balance) VALUES (?, ?), (?, ?)",
"Alice", 1000.00, "Bob", 500.00)
if err != nil {
log.Fatal("口座の初期化に失敗しました:", err)
}
fmt.Println("=== 初期状態 ===")
PrintBalances(db)
fmt.Println()
// 通常の送金:Alice → Bob 300
fmt.Println("=== Alice → Bob 送金 300 ===")
if err := Transfer(db, 1, 2, 300); err != nil {
fmt.Printf("送金に失敗しました: %v\n", err)
} else {
fmt.Println("送金に成功しました!")
}
PrintBalances(db)
fmt.Println()
// 残高不足の送金:Bob → Alice 900
fmt.Println("=== Bob → Alice 送金 900 ===")
if err := Transfer(db, 2, 1, 900); err != nil {
fmt.Printf("送金に失敗しました: %v\n", err)
} else {
fmt.Println("送金に成功しました!")
}
PrintBalances(db)
fmt.Println()
// 別の通常送金
fmt.Println("=== Bob → Alice 送金 100 ===")
if err := Transfer(db, 2, 1, 100); err != nil {
fmt.Printf("送金に失敗しました: %v\n", err)
} else {
fmt.Println("送金に成功しました!")
}
PrintBalances(db)
}
✓ 接続プールが設定されました:
最大接続数: 10
最大アイドル数: 5
接続存続期間: 5分
=== 初期状態 ===
口座残高:
[1] Alice 残高: 1000.00
[2] Bob 残高: 500.00
=== Alice → Bob 送金 300 ===
送金に成功しました!
口座残高:
[1] Alice 残高: 700.00
[2] Bob 残高: 800.00
=== Bob → Alice 送金 900 ===
送金に失敗しました: 残高不足: 現在 800.00、必要 900.00
口座残高:
[1] Alice 残高: 700.00
[2] Bob 残高: 800.00
=== Bob → Alice 送金 100 ===
送金に成功しました!
口座残高:
[1] Alice 残高: 800.00
[2] Bob 残高: 700.00
実世界のシナリオ
シナリオ1:ユーザー登録とログイン(MySQL)
MySQLを使用してユーザー情報を保存し、登録とパスワード検証を実装します。
package main
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"log"
"time"
_ "github.com/go-sql-driver/mysql" // MySQLドライバ
)
// hashPasswordはシンプルなパスワードハッシュ(本番ではbcryptを使用すべき)
func hashPassword(password string) string {
h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:])
}
// UserRepositoryはユーザーリポジトリ
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(dsn string) (*UserRepository, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
// 接続プールを設定
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(10 * time.Minute)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("データベース接続に失敗しました: %w", err)
}
return &UserRepository{db: db}, nil
}
func (r *UserRepository) Close() error {
return r.db.Close()
}
// Registerは新しいユーザーを登録する
func (r *UserRepository) Register(username, password, email string) (int64, error) {
hashedPwd := hashPassword(password)
result, err := r.db.Exec(
"INSERT INTO users (username, password_hash, email, created_at) VALUES (?, ?, ?, ?)",
username, hashedPwd, email, time.Now(),
)
if err != nil {
return 0, fmt.Errorf("登録に失敗しました: %w", err)
}
return result.LastInsertId()
}
// Loginはユーザーログインを検証する
func (r *UserRepository) Login(username, password string) (int, error) {
hashedPwd := hashPassword(password)
var id int
var storedHash string
err := r.db.QueryRow(
"SELECT id, password_hash FROM users WHERE username = ?",
username,
).Scan(&id, &storedHash)
if err == sql.ErrNoRows {
return 0, fmt.Errorf("ユーザーが存在しません")
}
if err != nil {
return 0, fmt.Errorf("クエリに失敗しました: %w", err)
}
if storedHash != hashedPwd {
return 0, fmt.Errorf("パスワードが正しくありません")
}
// 最終ログイン時刻を更新
_, _ = r.db.Exec(
"UPDATE users SET last_login = ? WHERE id = ?",
time.Now(), id,
)
return id, nil
}
func main() {
// DSN形式: username:password@tcp(host:port)/dbname?parseTime=true
dsn := "root:password@tcp(127.0.0.1:3306)/myapp?parseTime=true"
repo, err := NewUserRepository(dsn)
if err != nil {
log.Fatal("初期化に失敗しました:", err)
}
defer repo.Close()
// 登録
id, err := repo.Register("alice", "mypassword123", "alice@example.com")
if err != nil {
log.Fatal("登録に失敗しました:", err)
}
fmt.Printf("登録に成功しました、ユーザーID: %d\n", id)
// ログイン
userID, err := repo.Login("alice", "mypassword123")
if err != nil {
fmt.Printf("ログインに失敗しました: %v\n", err)
} else {
fmt.Printf("ログインに成功しました、ユーザーID: %d\n", userID)
}
// 間違ったパスワード
_, err = repo.Login("alice", "wrongpassword")
if err != nil {
fmt.Printf("ログインに失敗しました: %v\n", err)
}
}
シナリオ2:ページネーションクエリとバッチ挿入
大量のデータに対してページネーションクエリとバッチ挿入を効率的に処理します。
package main
import (
"database/sql"
"fmt"
"log"
"strings"
_ "github.com/mattn/go-sqlite3"
)
// Productは商品
type Product struct {
ID int
Name string
Price float64
Stock int
}
// PageResultはページネーション結果
type PageResult struct {
Items []Product
Total int
Page int
PageSize int
TotalPages int
}
// ProductStoreは商品ストレージ
type ProductStore struct {
db *sql.DB
}
func NewProductStore(db *sql.DB) (*ProductStore, error) {
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price REAL NOT NULL,
stock INTEGER DEFAULT 0
)
`)
if err != nil {
return nil, err
}
return &ProductStore{db: db}, nil
}
// BatchInsertはバッチ挿入する(トランザクションを使用してパフォーマンスを向上)
func (s *ProductStore) BatchInsert(products []Product) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
stmt, err := tx.Prepare("INSERT INTO products (name, price, stock) VALUES (?, ?, ?)")
if err != nil {
tx.Rollback()
return err
}
defer stmt.Close()
for _, p := range products {
_, err := stmt.Exec(p.Name, p.Price, p.Stock)
if err != nil {
tx.Rollback()
return fmt.Errorf("%s の挿入に失敗しました: %w", p.Name, err)
}
}
return tx.Commit()
}
// Paginateはページネーションクエリ
func (s *ProductStore) Paginate(page, pageSize int) (*PageResult, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 10
}
// 合計件数をクエリ
var total int
err := s.db.QueryRow("SELECT COUNT(*) FROM products").Scan(&total)
if err != nil {
return nil, err
}
// 総ページ数を計算
totalPages := total / pageSize
if total%pageSize > 0 {
totalPages++
}
// 現在のページデータをクエリ
offset := (page - 1) * pageSize
rows, err := s.db.Query(
"SELECT id, name, price, stock FROM products ORDER BY id LIMIT ? OFFSET ?",
pageSize, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock); err != nil {
return nil, err
}
items = append(items, p)
}
return &PageResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// Searchは名前であいまい検索する
func (s *ProductStore) Search(keyword string, page, pageSize int) (*PageResult, error) {
likePattern := "%" + keyword + "%"
var total int
err := s.db.QueryRow(
"SELECT COUNT(*) FROM products WHERE name LIKE ?", likePattern,
).Scan(&total)
if err != nil {
return nil, err
}
totalPages := total / pageSize
if total%pageSize > 0 {
totalPages++
}
offset := (page - 1) * pageSize
rows, err := s.db.Query(
"SELECT id, name, price, stock FROM products WHERE name LIKE ? ORDER BY id LIMIT ? OFFSET ?",
likePattern, pageSize, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Product
for rows.Next() {
var p Product
if err := rows.Scan(&p.ID, &p.Name, &p.Price, &p.Stock); err != nil {
return nil, err
}
items = append(items, p)
}
return &PageResult{
Items: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
func printPage(result *PageResult) {
fmt.Printf(" %d/%dページ(合計 %d 件)\n",
result.Page, result.TotalPages, result.Total)
for _, p := range result.Items {
fmt.Printf(" [%d] %-10s 価格: %6.2f 在庫: %d\n",
p.ID, p.Name, p.Price, p.Stock)
}
}
func main() {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
log.Fatal(err)
}
defer db.Close()
store, err := NewProductStore(db)
if err != nil {
log.Fatal(err)
}
// 50件の商品をバッチ挿入
var products []Product
names := []string{"キーボード", "マウス", "モニター", "ヘッドセット", "スピーカー",
"ウェブカメラ", "USBドライブ", "ハードドライブ", "RAM", "マザーボード"}
for i := 0; i < 50; i++ {
products = append(products, Product{
Name: fmt.Sprintf("%s-%02d", names[i%len(names)], i+1),
Price: float64(50 + i*10),
Stock: 100 - i,
})
}
if err := store.BatchInsert(products); err != nil {
log.Fatal("バッチ挿入に失敗しました:", err)
}
fmt.Printf("✓ %d 件の商品をバッチ挿入しました\n\n", len(products))
// ページネーションクエリ
fmt.Println("=== ページネーションクエリ(10件/ページ)===")
for page := 1; page <= 3; page++ {
result, err := store.Paginate(page, 10)
if err != nil {
log.Fatal(err)
}
printPage(result)
fmt.Println()
}
// 検索
fmt.Println("=== 'キーボード' を検索 ===")
result, err := store.Search("キーボード", 1, 10)
if err != nil {
log.Fatal(err)
}
printPage(result)
}
✓ 50 件の商品をバッチ挿入しました
=== ページネーションクエリ(10件/ページ)===
1/5ページ(合計 50 件)
[1] キーボード-01 価格: 50.00 在庫: 100
[2] マウス-02 価格: 60.00 在庫: 99
...
=== 'キーボード' を検索 ===
1/1ページ(合計 5 件)
[1] キーボード-01 価格: 50.00 在庫: 100
[11] キーボード-11 価格: 150.00 在庫: 90
[21] キーボード-21 価格: 250.00 在庫: 80
[31] キーボード-31 価格: 350.00 在庫: 70
[41] キーボード-41 価格: 450.00 在庫: 60
❓ よくある質問
質問1:なぜsql.Open()がunknown driverと表示されるのですか?
ドライバが正しくインポートされていません。匿名インポートを使用していることを確認してください:
import (
"database/sql"
_ "github.com/mattn/go-sqlite3" // この行が必要です
)
_はパッケージのinit()関数(ドライバの登録)のみを実行し、パッケージ名を直接参照しないことを意味します。ドライバのインポートを忘れるのは初心者によくある間違いです。
質問2:データがない場合にQueryRow().Scan()がエラーを報告するのはなぜですか?
クエリに一致する行がない場合、Scan()はsql.ErrNoRowsを返します。個別に処理する必要があります:
var name string
err := db.QueryRow("SELECT name FROM users WHERE id = ?", 999).Scan(&name)
if err == sql.ErrNoRows {
fmt.Println("ユーザーが存在しません")
} else if err != nil {
log.Fatal("クエリエラー:", err)
}
質問3:db.Close()はいつ呼び出すべきですか?
sql.DBは接続プールであり、プログラムのライフサイクル全体を通じて開いたままにすべきで、各操作後に閉じるべきではありません。通常、main()関数の冒頭でdefer db.Close()を使用します:
func main() {
db, err := sql.Open("sqlite3", "./mydb.db")
if err != nil {
log.Fatal(err)
}
defer db.Close() // プログラム終業時のみ閉じる
// ... dbを使用してさまざまな操作を行う
}
質問4:SQLインジェクションを防ぐには?
文字列連結でSQLステートメントを構築しないでください。常にパラメータ化クエリを使用してください:
// ❌ 危険!SQLインジェクションのリスク
query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", userInput)
// ✓ 安全:パラメータプレースホルダーを使用
db.Query("SELECT * FROM users WHERE name = ?", userInput)
?プレースホルダーはドライバによって自動的にエスケープされ、データベースはSQLコードとしてではなく純粋なデータとして扱います。
📖 まとめ
このレッスンでは以下を学びました:
| トピック | ポイント |
|---|---|
database/sql |
Go標準ライブラリの統一インターフェース、任意のデータベースドライバをサポート |
| ドライバのインポート | _で匿名インポートする必要があり、ドライバのみ登録される |
sql.Open |
接続プールを作成、すぐに接続しない。Ping()で検証する |
| CRUD | Execで書き込み操作、Query/QueryRowでデータを読み取る |
| プリペアドステートメント | Prepareでパフォーマンスを向上、SQLインジェクションを防止 |
| トランザクション | Begin→Exec→Commit/Rollback、アトミック性を保証 |
| 接続プール | SetMaxOpenConns/SetMaxIdleConnsなどのチューニング方法 |
Scan |
データベースカラムをGo変数にマッピング |
sql.ErrNoRows |
クエリが結果を返さない場合の特別なエラー、個別の処理が必要 |
これらのスキルにより、Goプロジェクトでリレーショナルデータベースを操作する完全な能力を身につけました。
📝 演習
演習1:基本CRUD(ウォームアップ)
SQLiteデータベースを作成し、booksテーブル(フィールド:id、title、author、price、published_year)を作成して、以下の操作を完了してください:
- 少なくとも5冊の本を挿入
- 価格が50より大きい本をクエリ
- 本の価格を更新
- 2000年以前に出版された本を削除
演習2:トランザクションとエラー処理
演習1に基づいて、TransferBook関数を実装してください:著者から別の著者へすべての本を転送します(トランザクションでアトミック性を保証)。要件:
- 元の著者が存在しない場合、ロールバックしてエラーを返す
- 更新成功後、影響を受けた行数を出力
defer+recoverを使用して、パニックがトランザクションのハングを引き起こすのを防止
演習3:総合チャレンジ — ミニ図書管理システム
以下の要件を持つコマンドライン図書管理システムを構築してください:
- CRUD操作とページネーションリストをサポート
- DAOパターンを使用してデータベース操作をカプセル化
- タイトルでのあいまい検索と価格範囲によるフィルタリングをサポート
- プリペアドステートメントを使用して繰り返しクエリを最適化
- 適切な接続プールパラメータを設定
log.Fatalの代わりに構造化されたエラー処理を使用
次のレッスン:レッスン28:プロジェクトデプロイ — Goプロジェクトのコンパイル、パッケージング、サーバーへのデプロイ方法を学びます。



