Goアプリ開発に役立つSQLite3活用術


Goアプリ開発者のためのSQLite3徹底活用術:入門から応用、ベストプラクティスまで

Go言語でアプリケーションを開発する際、データの永続化は重要な課題です。様々なデータベースの選択肢がある中で、SQLite3はその手軽さ、軽量性、そして信頼性の高さから、多くのGo開発者に利用されています。特に、スタンドアロンアプリケーション、デスクトップツール、モバイルアプリケーション、組み込みシステム、あるいは小規模なWebサービスなど、専用のデータベースサーバーを立てる必要がない、または立てたくないシナリオにおいて、SQLite3は非常に強力な選択肢となります。

この記事では、Go言語を使用してSQLite3を効果的に活用するための方法を、基本的な接続から高度なトランザクション管理、同時実行性の考慮、パフォーマンス最適化、テスト方法まで、幅広く、かつ詳細に解説します。約5000語のボリュームで、Go開発者がSQLite3を使いこなし、堅牢で効率的なアプリケーションを構築できるようになることを目指します。

はじめに:なぜGoアプリ開発でSQLite3なのか?

Go言語はそのシンプルさ、高い並行処理能力、そして強力な標準ライブラリから、幅広い分野で利用されています。一方で、SQLite3は世界で最も広く利用されているデータベースエンジンの1つであり、以下の特徴を持っています。

  • サーバーレス、ゼロコンフィグ: 独立したデーモンプロセスとして動作せず、アプリケーション内にライブラリとして組み込まれます。設定ファイルや管理プロセスは不要です。データベース全体が単一のファイルとして保存されるため、配布やバックアップが容易です。
  • トランザクションサポート: 完全なACID(Atomicity, Consistency, Isolation, Durability)準拠のトランザクションをサポートしており、データの整合性を保証します。
  • 標準SQL互換: 多くの標準SQL構文をサポートしています。
  • 軽量かつ高速: ディスクI/Oが少なく、シンプルなクエリであれば非常に高速に動作します。
  • 広く利用されている実績: 多くのオペレーティングシステム、ブラウザ、アプリケーションで内部的に利用されており、その信頼性は証明されています。

Go言語とSQLite3の組み合わせは非常に自然です。GoアプリケーションのバイナリにSQLite3ライブラリを組み込むことで、外部に依存しない単一の実行可能ファイルを配布できます。これは、デスクトップアプリケーションや、設定の手間を最小限に抑えたいWebサービスなどに特に有利です。

この記事を通じて、Goの標準ライブラリである database/sql パッケージと、SQLite3ドライバーである github.com/mattn/go-sqlite3 を中心に、SQLite3をGoアプリケーションに組み込むための実践的な知識とテクニックを習得できます。

SQLite3の基本

SQLite3は、リレーショナルデータベース管理システム(RDBMS)の一種ですが、一般的なRDBMS(PostgreSQL, MySQLなど)とは異なり、クライアント・サーバー型ではありません。アプリケーションのプロセス内でライブラリとして動作する組み込み型データベースです。

特徴の再確認:

  • サーバーレス: 独立したサーバープロセスがありません。アプリケーションが直接データベースファイルにアクセスします。
  • ゼロコンフィグ: インストールや設定がほとんど不要です。データベースは単一のファイル(デフォルト)またはメモリ上に作成されます。
  • トランザクション: 強力なACIDトランザクションをサポートします。
  • データ型: 柔軟なデータ型を扱います。内部的にはINTEGER, REAL, TEXT, BLOB, NULLの5つのストレージクラスを持ち、宣言されたデータ型はあくまで推奨として扱われます(ただし、厳密な型チェックを有効にするオプションもあります)。
  • スケーラビリティ: 並行読み込みには強いですが、並行書き込みには制限があります。これは、通常、データベースファイル全体にロックをかけるためです(ただし、WALモードなどにより改善されます)。

これらの特徴から、SQLite3は以下のような用途に非常に適しています。

  • デスクトップアプリケーションの設定保存やデータ管理
  • モバイルアプリケーションのローカルデータストア
  • 組み込みシステム
  • テスト用途のインメモリデータベース
  • 小規模なWebサービスのデータストア
  • 単一ユーザー、または読み込み中心の複数のユーザーがアクセスするアプリケーション

逆に、高負荷な並行書き込みが頻繁に発生する大規模なWebサービスや、分散システムの中核となるデータベースとしては不向きです。

Go言語からのSQLite3利用入門

GoでSQLite3を利用するには、Goの標準ライブラリ database/sql と、SQLite3ドライバーが必要です。database/sql パッケージは、様々なデータベースとやり取りするための汎用的なインターフェースを提供し、ドライバーはそのインターフェースを特定のデータベース(この場合はSQLite3)に対して実装します。

必要なライブラリのインストール:

SQLite3ドライバーとして、github.com/mattn/go-sqlite3 が最も広く利用されています。

bash
go get github.com/mattn/go-sqlite3

これにより、プロジェクトのモジュールにドライバーが追加されます。

データベースへの接続と切断:

database/sql パッケージの Open 関数を使用してデータベースに接続します。

“`go
import (
“database/sql”
_ “github.com/mattn/go-sqlite3” // SQLite3ドライバーをインポート(_ はブランクインポート)
“log”
“os”
)

func main() {
// データベースファイル名
dbFile := “./my_database.db”

// データベースに接続
// "sqlite3" はドライバー名
// dbFile は接続文字列(ファイルパス)
db, err := sql.Open("sqlite3", dbFile)
if err != nil {
    log.Fatal(err)
}
defer db.Close() // 関数終了時にデータベース接続を閉じる

// 接続ができているか確認
err = db.Ping()
if err != nil {
    log.Fatal(err)
}

log.Println("データベースに接続しました。")

// ここでデータベース操作を行う...

}
“`

sql.Open の第一引数はドライバー名、第二引数は接続文字列です。SQLite3の場合、接続文字列はデータベースファイルのパスになります。:memory: と指定すると、メモリ上に一時的なデータベースが作成され、接続が閉じられると消滅します。ファイルパスを指定すると、そのファイルが存在すれば接続し、存在しなければ新規作成します。

_ "github.com/mattn/go-sqlite3" のようにブランクインポートしているのは、パッケージ内の init 関数が実行され、ドライバーが database/sql に登録されるだけで、パッケージ内のエクスポートされた機能自体は直接使用しないためです。

defer db.Close() は、データベース接続を適切に閉じるためのGoの慣習です。これにより、関数が正常終了またはエラー発生に関わらず、接続が確実に閉じられます。

db.Ping() は、データベースへの接続が有効であることを確認するためのメソッドです。

簡単なCRUD操作:

データベースへの接続ができたら、テーブルを作成したり、データを操作したりできます。database/sql パッケージには、クエリを実行するための主なメソッドとして Exec, Query, QueryRow があります。

  • Exec: INSERT, UPDATE, DELETE, CREATE TABLEなどの、結果セットを返さないクエリを実行します。
  • Query: SELECTクエリのように、複数の行を含む結果セットを返すクエリを実行します。
  • QueryRow: SELECTクエリのように、単一の行を返すことが期待されるクエリを実行します。

テーブル作成 (CREATE TABLE):

``go
func createTable(db *sql.DB) error {
createSQL :=

CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`

_, err := db.Exec(createSQL)
if err != nil {
    return err
}
log.Println("テーブル 'users' が作成または既に存在します。")
return nil

}

func main() {
// … データベース接続コード …

if err := createTable(db); err != nil {
    log.Fatal(err)
}

// ...

}
“`

Exec メソッドは sql.Resulterror を返します。sql.Result には、影響を受けた行数や自動生成されたIDなどの情報が含まれますが、ここでは不要なので _ で受け流しています。CREATE TABLE IF NOT EXISTS を使用すると、テーブルが既に存在する場合でもエラーになりません。

データ挿入 (INSERT):

``go
func insertUser(db *sql.DB, name, email string) (int64, error) {
insertSQL :=
INSERT INTO users (name, email) VALUES (?, ?);`

// プリペアドステートメントを使用
stmt, err := db.Prepare(insertSQL)
if err != nil {
    return 0, err
}
defer stmt.Close() // ステートメントを閉じる

// プレースホルダに値をバインドして実行
result, err := stmt.Exec(name, email)
if err != nil {
    return 0, err
}

// 自動生成されたIDを取得
id, err := result.LastInsertId()
if err != nil {
    return 0, err
}

log.Printf("ユーザー %s (%s) を挿入しました。ID: %d\n", name, email, id)
return id, nil

}

func main() {
// … データベース接続、テーブル作成コード …

userID, err := insertUser(db, "Alice", "[email protected]")
if err != nil {
    log.Fatal(err)
}
log.Printf("挿入されたユーザーID: %d\n", userID)

// ...

}
“`

ここでは プリペアドステートメント を使用しています。db.Prepare でSQLクエリを準備し、stmt.Exec でプレースホルダ (?) に値をバインドして実行します。プリペアドステートメントを使用することには以下の利点があります。

  1. SQLインジェクション対策: ユーザー入力値を直接SQL文字列に埋め込むのではなく、データベースが安全な方法で値を処理するため、SQLインジェクション攻撃を防ぐことができます。これはデータベース操作において非常に重要です。常にプリペアドステートメントを使用すべきです。
  2. パフォーマンス: 同じクエリを繰り返し実行する場合、クエリの解析や最適化が一度で済むため、パフォーマンスが向上する可能性があります。

result.LastInsertId() は、自動生成された主キーの値を取得します。

データ選択 (SELECT):

単一の行を取得する場合 (QueryRow):

“`go
type User struct {
ID int
Name string
Email string
CreatedAt string // SQLiteのDATETIMEは文字列として取得するのが一般的
}

func getUserByID(db sql.DB, id int) (User, error) {
selectSQL := SELECT id, name, email, created_at FROM users WHERE id = ?;

// プリペアドステートメントを使用
stmt, err := db.Prepare(selectSQL)
if err != nil {
    return nil, err
}
defer stmt.Close()

// QueryRowで単一の行を取得
row := stmt.QueryRow(id)

var user User
// Scanで行のカラムを構造体のフィールドに読み込む
err = row.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt)
if err != nil {
    // sql.ErrNoRows は行が見つからなかった場合のエラー
    if err == sql.ErrNoRows {
        return nil, nil // ユーザーが見つからなかった場合は nil を返す
    }
    return nil, err
}

log.Printf("ユーザー情報: %+v\n", user)
return &user, nil

}

func main() {
// … データベース接続、テーブル作成、挿入コード …

user, err := getUserByID(db, 1) // 挿入したユーザーのID (仮に1とする)
if err != nil {
    log.Fatal(err)
}
if user != nil {
    log.Printf("取得したユーザー名: %s\n", user.Name)
} else {
    log.Println("ユーザーは見つかりませんでした。")
}

// ...

}
“`

QueryRow は単一の sql.Row オブジェクトを返します。sql.RowScan メソッドを使って、結果セットのカラムをGoの変数に読み込みます。Scan の引数はポインタである必要があります。行が見つからなかった場合、Scansql.ErrNoRows を返します。

複数の行を取得する場合 (Query):

``go
func getAllUsers(db *sql.DB) ([]User, error) {
selectSQL :=
SELECT id, name, email, created_at FROM users;`

// プリペアドステートメントを使用(ここではプレースホルダはないが習慣として)
stmt, err := db.Prepare(selectSQL)
if err != nil {
    return nil, err
}
defer stmt.Close()

// Queryで複数の行を取得
rows, err := stmt.Query()
if err != nil {
    return nil, err
}
defer rows.Close() // rowsも必ず閉じる必要がある

var users []User
// Next()で次の行に進む
for rows.Next() {
    var user User
    // Scanで行のカラムを読み込む
    if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt); err != nil {
        // 途中でエラーが発生した場合も処理を中断
        return nil, err
    }
    users = append(users, user)
}

// Next()ループの後にrows.Err()をチェック
// これはループ中に発生した可能性がある非致命的なエラーを検出するため
if err := rows.Err(); err != nil {
    return nil, err
}

log.Printf("全ユーザー情報を取得しました (%d件)。\n", len(users))
return users, nil

}

func main() {
// … データベース接続、テーブル作成、挿入コード …

// 別のユーザーを挿入
_, err := insertUser(db, "Bob", "[email protected]")
if err != nil {
    log.Fatal(err)
}

users, err := getAllUsers(db)
if err != nil {
    log.Fatal(err)
}

for _, user := range users {
    log.Printf("ユーザー: ID=%d, Name=%s, Email=%s\n", user.ID, user.Name, user.Email)
}

// ...

}
“`

Querysql.Rows オブジェクトを返します。rows.Next() メソッドをループで呼び出し、次の行があるかを確認します。行がある場合、その行のデータを rows.Scan で変数に読み込みます。rows オブジェクトは使用後に必ず rows.Close() で閉じる必要があります。defer rows.Close() を使用するのが一般的です。また、ループの最後に rows.Err() をチェックするのも重要です。

データ更新 (UPDATE):

``go
func updateUserEmail(db *sql.DB, id int, newEmail string) error {
updateSQL :=
UPDATE users SET email = ? WHERE id = ?;`

stmt, err := db.Prepare(updateSQL)
if err != nil {
    return err
}
defer stmt.Close()

result, err := stmt.Exec(newEmail, id)
if err != nil {
    return err
}

// 更新された行数を取得
rowsAffected, err := result.RowsAffected()
if err != nil {
    return err
}

log.Printf("ユーザーID %d のメールアドレスを更新しました。影響を受けた行数: %d\n", id, rowsAffected)
return nil

}

func main() {
// … データベース接続、テーブル作成、挿入、選択コード …

// ユーザーID 1 のメールアドレスを更新
err := updateUserEmail(db, 1, "[email protected]")
if err != nil {
    log.Fatal(err)
}

// 更新後のユーザー情報を再度取得して確認
updatedUser, err := getUserByID(db, 1)
if err != nil {
    log.Fatal(err)
}
if updatedUser != nil {
    log.Printf("更新後のメールアドレス: %s\n", updatedUser.Email)
}

// ...

}
“`

Exec メソッドは、更新や削除などの操作で影響を受けた行数を result.RowsAffected() で取得できます。

データ削除 (DELETE):

``go
func deleteUser(db *sql.DB, id int) error {
deleteSQL :=
DELETE FROM users WHERE id = ?;`

stmt, err := db.Prepare(deleteSQL)
if err != nil {
    return err
}
defer stmt.Close()

result, err := stmt.Exec(id)
if err != nil {
    return err
}

rowsAffected, err := result.RowsAffected()
if err != nil {
    return err
}

log.Printf("ユーザーID %d を削除しました。影響を受けた行数: %d\n", id, rowsAffected)
return nil

}

func main() {
// … データベース接続、テーブル作成、挿入、選択、更新コード …

// ユーザーID 2 を削除(Bobを削除する想定)
err := deleteUser(db, 2)
if err != nil {
    log.Fatal(err)
}

// 削除後のユーザー一覧を確認
remainingUsers, err := getAllUsers(db)
if err != nil {
    log.Fatal(err)
}
log.Printf("残りのユーザー数: %d\n", len(remainingUsers))

// ...

}
“`

削除も更新と同様に Exec メソッドを使用し、result.RowsAffected() で削除された行数を確認できます。

高度なSQLite3活用術

基本的なCRUD操作を理解したところで、より堅牢で実用的なGoアプリケーションを構築するための高度な活用術を見ていきましょう。

トランザクション管理:

データベース操作において、複数のステップを不可分な一つの単位として扱いたい場合があります。例えば、銀行口座Aから口座Bへ送金する処理は、「口座Aから引き出す」と「口座Bに入金する」という二つのステップからなります。もし片方だけが成功し、もう片方が失敗した場合、データは不正な状態になります。これを防ぐためにトランザクションを使用します。トランザクション内のすべての操作が成功するか、あるいは一つでも失敗したらすべての操作がロールバック(取り消し)されるかのどちらかになります。

Goの database/sql パッケージでは、db.BeginTx メソッドでトランザクションを開始します。

“`go
func transferMoney(db *sql.DB, fromAccountID, toAccountID int, amount float64) error {
// トランザクションを開始
tx, err := db.BeginTx(context.Background(), nil) // nilはデフォルトのオプション
if err != nil {
return err
}

// defer func()でトランザクションをロールバックまたはコミット
// 関数がpanicした場合もロールバックされるようにrecoverも考慮する
defer func() {
    if p := recover(); p != nil {
        tx.Rollback()
        panic(p) // panicを再発生させる
    } else if err != nil {
        // errがnilでない場合(エラーが発生した場合)、ロールバック
        tx.Rollback()
    } else {
        // エラーが発生しなかった場合、コミット
        err = tx.Commit()
        if err != nil {
            log.Printf("トランザクションコミット失敗: %v\n", err)
            // コミットに失敗した場合の追加処理が必要なことも
        }
    }
}()

// 口座Aから引き出し
updateFromSQL := `UPDATE accounts SET balance = balance - ? WHERE id = ?;`
// tx.Execを使う(通常のdb.Execではなく)
resultFrom, err := tx.Exec(updateFromSQL, amount, fromAccountID)
if err != nil {
    return err // defer funcによりロールバックされる
}
rowsAffectedFrom, err := resultFrom.RowsAffected()
if err != nil {
    return err
}
if rowsAffectedFrom == 0 {
    // 該当口座が存在しないなどの場合
    err = fmt.Errorf("口座ID %d が見つからないか残高不足です", fromAccountID)
    return err
}

// 口座Bに入金
updateToSQL := `UPDATE accounts SET balance = balance + ? WHERE id = ?;`
// tx.Execを使う
resultTo, err := tx.Exec(updateToSQL, amount, toAccountID)
if err != nil {
    return err // defer funcによりロールバックされる
}
rowsAffectedTo, err := resultTo.RowsAffected()
if err != nil {
    return err
}
if rowsAffectedTo == 0 {
    // 該当口座が存在しないなどの場合
    err = fmt.Errorf("口座ID %d が見つかりません", toAccountID)
    return err
}

// ここまでエラーがなければ defer func でコミットされる
log.Printf("口座ID %d から口座ID %d へ %f を送金しました。\n", fromAccountID, toAccountID, amount)
return nil

}
“`

トランザクション内で実行するクエリは、通常の db.Exec, db.Query, db.QueryRow ではなく、tx.Exec, tx.Query, tx.QueryRow を使用します。トランザクションが開始されると、そのトランザクション内で実行された変更は、tx.Commit() が呼び出されるまで他の接続からは見えません(隔離レベルによりますが、SQLiteのデフォルトでは見えません)。tx.Commit() が成功すると、すべての変更が永続化されます。エラーが発生した場合や、tx.Rollback() が呼び出された場合、トランザクション内で実行されたすべての変更は破棄され、データベースはトランザクション開始前の状態に戻ります。

上記の例では、defer文と無名関数を使って、エラーが発生した場合はロールバックし、そうでない場合はコミットするという処理を統一的に扱っています。panicが発生した場合も考慮すると、少し複雑なdefer文になります。シンプルなケースでは、エラーチェックのたびに tx.Rollback() を呼び出し、最後に tx.Commit() を呼び出す形式でも構いません。

エラーハンドリング:

データベース操作では様々なエラーが発生します。接続エラー、SQL構文エラー、制約違反(UNIQUE制約など)、行が見つからないエラー (sql.ErrNoRows)、トランザクション中のエラーなどです。

重要なのは、エラーの種類に応じて適切に処理を分けることです。

  • sql.ErrNoRows: これは行が見つからなかったことを示すエラーであり、多くの場合は致命的ではありません。呼び出し元でこのエラーをチェックし、例えば「データが存在しません」というメッセージを表示するなど適切に処理します。
  • その他のエラー: SQL構文エラーや接続エラーなどは、通常はプログラムの論理的な問題や環境の問題を示すため、ログに記録してアプリケーションの実行を停止するか、エラーを上位層に伝播させて適切に処理する必要があります。

トランザクション中のエラーは特に重要です。前述の例のように、トランザクション内でエラーが発生した場合は必ずロールバックする必要があります。

“`go
func safeDatabaseOperation(db *sql.DB) (err error) {
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
return err
}

defer func() {
    if r := recover(); r != nil {
        // パニックが発生した場合
        log.Printf("Recovered from panic: %v", r)
        tx.Rollback() // ロールバック
        panic(r) // 再パニックさせる
    } else if err != nil {
        // エラーが発生した場合
        log.Printf("Rolling back transaction due to error: %v", err)
        tx.Rollback() // ロールバック
    } else {
        // エラーが発生しなかった場合
        commitErr := tx.Commit() // コミット
        if commitErr != nil {
            log.Printf("Failed to commit transaction: %v", commitErr)
            err = commitErr // commitエラーを返す
        }
    }
}()

// ここでtxを使ったデータベース操作を行う
// 例えば:
// _, err = tx.Exec("INSERT INTO some_table (value) VALUES (?)", "test")
// if err != nil {
//     return err // defer funcがロールバックする
// }

// ここで発生する可能性があるエラーをすべて err 変数に代入する
// return nil // すべて成功した場合
return nil // サンプルなので常に成功とする

}
“`

このようなパターンは、トランザクションの開始からコミット/ロールバック、エラー処理、パニックからの回復を集中管理するのに役立ちます。

同時実行性(Concurrency):

Goはゴルーチンによる高い並行処理能力を持っています。複数のゴルーチンから同時にSQLite3データベースにアクセスする場合、注意が必要です。

SQLite3は、デフォルト設定では、データベースファイル全体に対して書き込みロックをかけます。つまり、あるゴルーチンが書き込みを行っている間、他のゴルーチンは書き込みも読み込みもブロックされる可能性があります(読み込みは別の仕組みで並行可能ですが、書き込みは排他制御されます)。これは特に、複数の書き込みが頻繁に発生する場合にパフォーマンスのボトルネックとなり得ます。

この問題を軽減するために、SQLite3には WAL (Write-Ahead Logging) モード があります。WALモードでは、変更はまずWALファイルに記録され、後でメインのデータベースファイルに書き込まれます。これにより、読み込みと書き込みを並行して行うことが可能になります(ただし、複数の書き込みは依然として直列化されます)。

WALモードを有効にするには、接続文字列に _journal_mode=WAL を追加するか、接続後に PRAGMA journal_mode=WAL; クエリを実行します。

“`go
// WALモードを有効にして接続
dbFile := “./my_database_wal.db”
db, err := sql.Open(“sqlite3”, dbFile + “?_journal_mode=WAL”)
if err != nil {
log.Fatal(err)
}
defer db.Close()

// 接続プール設定
// 最大オープン接続数(同時実行されるゴルーチン数などに応じて調整)
db.SetMaxOpenConns(10)
// アイドル接続の最大数
db.SetMaxIdleConns(5)
// 接続の再利用時間
db.SetConnMaxLifetime(time.Hour) // 必要に応じて設定

// 接続プールが構成されることで、複数のゴルーチンからdbオブジェクトを安全に共有できる
“`

Goの database/sql パッケージは内部的に接続プールを管理します。複数のゴルーチンから同じ sql.DB オブジェクトを使用することは安全です。database/sql が、必要に応じて接続をプールから取得したり、新しい接続を作成したり、接続を解放したりしてくれます。SetMaxOpenConns などの設定で、プールの挙動を制御できます。高い並行性が必要な場合は、SetMaxOpenConns の値を適切に設定することが重要です。

ただし、SQLite3はあくまで単一ファイルベースのデータベースであり、PostgreSQLやMySQLのようなクライアント・サーバー型データベースと比較すると、真の意味での並行書き込み性能には限界があります。非常に高い並行書き込みスループットが必要な場合は、SQLite3以外のデータベースを検討すべきです。

構造体とのマッピング:

データベースから取得したデータをGoの構造体にマッピングする作業は一般的です。前述の getUserByIDgetAllUsers の例では、rows.Scan を使って手動でカラムを構造体のフィールドに読み込みました。フィールドが多い構造体の場合、この作業は冗長でエラーを起こしやすくなります。

このようなマッピングを簡単にするために、sqlx のような外部ライブラリがよく利用されます。sqlxdatabase/sql をラップし、構造体のタグ (db:"column_name") を利用して自動的にスキャンする機能を提供します。

sqlx のインストール:

bash
go get github.com/jmoiron/sqlx

sqlx を使用したSELECT例:

“`go
import (
“github.com/jmoiron/sqlx”
_ “github.com/mattn/go-sqlite3”
“log”
)

type User struct {
ID int db:"id"
Name string db:"name"
Email string db:"email"
CreatedAt string db:"created_at" // または time.Time を適切に扱う
}

func getUserByIDEfficient(db sqlx.DB, id int) (User, error) {
selectSQL := SELECT id, name, email, created_at FROM users WHERE id = ?;

var user User
// sqlx.Get で単一の行を取得し、構造体に直接スキャン
err := db.Get(&user, selectSQL, id) // 第二引数以降はプレースホルダの値
if err != nil {
    if err == sql.ErrNoRows {
        return nil, nil // 行が見つからない
    }
    return nil, err
}

log.Printf("ユーザー情報 (sqlx): %+v\n", user)
return &user, nil

}

func getAllUsersEfficient(db *sqlx.DB) ([]User, error) {
selectSQL := SELECT id, name, email, created_at FROM users;

var users []User
// sqlx.Select で複数の行を取得し、構造体のスライスに直接スキャン
err := db.Select(&users, selectSQL)
if err != nil {
    return nil, err
}

log.Printf("全ユーザー情報 (sqlx) を取得しました (%d件)。\n", len(users))
return users, nil

}

func main() {
// sqlx.Open を使用して接続
db, err := sqlx.Open(“sqlite3”, “./my_database_sqlx.db”)
if err != nil {
log.Fatal(err)
}
defer db.Close()

// sqlx.DB は通常の sql.DB と互換性がある部分もある
// 例: sqlx.DB.Exec は sql.DB.Exec と同じ
// ただし、sqlxの拡張機能(Get, Selectなど)を使う場合はsqlx.DB型が必要

// ... テーブル作成やデータ挿入は通常のsql.DBメソッドやsqlx.DB.Execなどで可能 ...
// 簡単なテーブル作成例 (sqlx.DBはExecを持つ):
createSQL := `CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT UNIQUE, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`
_, err = db.Exec(createSQL)
if err != nil {
    log.Fatal(err)
}

// データ挿入例:
insertSQL := `INSERT INTO users (name, email) VALUES (?, ?);`
_, err = db.Exec(insertSQL, "Charlie", "[email protected]")
if err != nil {
    log.Fatal(err)
}

// sqlxを使った取得
user, err := getUserByIDEfficient(db, 1) // ID 1 のユーザーを取得
if err != nil {
    log.Fatal(err)
}
if user != nil {
    log.Printf("取得ユーザー名: %s\n", user.Name)
}

users, err := getAllUsersEfficient(db)
if err != nil {
    log.Fatal(err)
}
for _, u := range users {
    log.Printf("ユーザー: ID=%d, Name=%s\n", u.ID, u.Name)
}

}
“`

sqlx.Opensql.Open と同様に接続しますが、返されるのは *sqlx.DB 型になります。この型は sql.DB の機能をすべて持ちつつ、GetSelect といった sqlx 独自のマッピング機能を提供します。Get は単一の構造体へのスキャン、Select は構造体のスライスへのスキャンに使用します。構造体のフィールドに付けた db:"column_name" タグによって、どのデータベースカラムをどのフィールドにマッピングするかが指定されます。

スキーマ管理とマイグレーション:

アプリケーションを開発・改良していくにつれて、データベースのスキーマ(テーブル構造)を変更する必要が出てきます。テーブルの追加、カラムの追加・削除、制約の変更などです。これらの変更を追跡し、データベースのバージョンを管理するプロセスを「データベースマイグレーション」と呼びます。

簡単なアプリケーションであれば、起動時に CREATE TABLE IF NOT EXISTS を実行するだけで済むかもしれません。しかし、既存のデータがある状態でスキーマを変更したり、複数のバージョンを管理したりするには、より体系的なアプローチが必要です。

マイグレーションの方法はいくつかあります。

  1. 手動でSQLスクリプトを作成し実行: 各バージョンアップごとに 001_create_users_table.sql, 002_add_index_to_email.sql のようなファイルを作成し、アプリケーションの起動時などにこれらのスクリプトが適用されているかを確認して、まだ適用されていないものを順に実行します。どのスクリプトが実行済みかを記録するための schema_migrations のようなテーブルを別途作成するのが一般的です。
  2. マイグレーションツールを使用: migrate (github.com/golang-migrate/migrate) のような専用ツールを利用します。これらのツールは、マイグレーションファイルの管理、適用、ロールバックなどの機能を提供します。

migrate ツールの簡単な例:

まずツールをインストールします。

bash
go get -u github.com/golang-migrate/migrate/v4
go install github.com/golang-migrate/migrate/v4/cmd/migrate

マイグレーションファイルを作成します。

bash
migrate create -ext sql -dir database/migrations create_users_table

これにより、database/migrations ディレクトリにタイムスタンプ付きのファイルが2つ作成されます(up 用と down 用)。

database/migrations/
└── 20231027100000_create_users_table.up.sql
└── 20231027100000_create_users_table.down.sql

それぞれのファイルにSQLを書きます。

20231027100000_create_users_table.up.sql:

“`sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users (email);
“`

20231027100000_create_users_table.down.sql:

sql
DROP TABLE users;

Goアプリケーションからこれらのマイグレーションを実行するには、migrate ライブラリをGoコード内で使用します。

“`go
import (
“database/sql”
“fmt”
“log”

"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/sqlite3" // DBドライバ
_ "github.com/golang-migrate/migrate/v4/source/file"     // ソースドライバ(ファイルシステム)
_ "github.com/mattn/go-sqlite3"                         // SQLite3 DBドライバ

)

func runMigrations(dbFile string) error {
// migrateライブラリが使用するDB URL形式
dbURL := fmt.Sprintf(“sqlite3://%s”, dbFile)

// ソースURL(マイグレーションファイルがある場所)
// 相対パスは実行時のカレントディレクトリに注意
sourceURL := "file://database/migrations" // 例えば ./database/migrations

m, err := migrate.New(sourceURL, dbURL)
if err != nil {
    return fmt.Errorf("マイグレーションインスタンスの作成に失敗: %w", err)
}
defer m.Close()

// Upメソッドで未適用分をすべて適用
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
    return fmt.Errorf("マイグレーションの実行に失敗: %w", err)
}

if err == migrate.ErrNoChange {
    log.Println("適用すべきマイグレーションはありませんでした。")
} else {
    log.Println("マイグレーションが正常に完了しました。")
}

return nil

}

func main() {
dbFile := “./my_database.db”

// データベースに接続(migrateは内部でも接続するので、ここでは単にファイルパスを渡すだけでも良いが、
// 後のDB操作のために接続しておくことが多い)
db, err := sql.Open("sqlite3", dbFile)
if err != nil {
    log.Fatal(err)
}
defer db.Close()

// マイグレーションを実行
if err := runMigrations(dbFile); err != nil {
    log.Fatal(err)
}

// ... アプリケーションの起動処理 ...

}
“`

アプリケーションの起動時にこの runMigrations 関数を呼び出すことで、データベーススキーマが最新の状態に保たれます。

パフォーマンス最適化:

SQLite3のパフォーマンスは、適切にチューニングすることで向上させることができます。

  1. インデックスの活用: WHERE 句や JOIN 条件で頻繁に使用されるカラムにはインデックスを作成します。これにより、データの検索速度が大幅に向上します。
    sql
    CREATE INDEX idx_users_email ON users (email); -- emailカラムにインデックスを作成

    インデックスは書き込み操作(INSERT, UPDATE, DELETE)のオーバーヘッドを増加させるため、必要最小限に留めるべきです。

  2. EXPLAIN QUERY PLAN の使用: クエリがどのように実行されるかを確認し、非効率な部分(フルテーブルスキャンなど)を特定できます。
    sql
    EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = '[email protected]';

    出力を見て、インデックスが使用されているかなどを確認します。

  3. VACUUM コマンド: DELETEやUPDATEによって解放されたディスク容量は、デフォルトではOSに返却されず、データベースファイル内に「空き領域」として保持されます。データベースファイルが肥大化したり、フラグメンテーションが進んだりすることがあります。VACUUM コマンドはデータベースを再構築し、未使用の領域を回収してファイルを最適化します。
    sql
    VACUUM;

    VACUUM はデータベースファイル全体を書き換えるため、大きなデータベースでは時間がかかり、その間はデータベースがロックされることに注意が必要です。頻繁に実行する必要はありませんが、データベースファイルが不必要に大きくなった場合に検討します。

  4. PRAGMA文による設定調整: SQLite3には様々な設定を調整できるPRAGMA文があります。パフォーマンスや耐久性に関わる主要な設定には以下があります。

    • PRAGMA journal_mode: 前述のWALモード (WAL)、デフォルトのdelete (DELETE), persist (PERSIST), truncate (TRUNCATE), memory (MEMORY), off (OFF) などがあります。通常は WAL が推奨されます。
    • PRAGMA synchronous: データの書き込みがディスクに物理的に書き込まれることをOSに強制する度合いを設定します。FULL (最も安全だが遅い)、NORMAL (通常は十分な安全性と速度)、OFF (最も速いがクラッシュ時にデータ損失の可能性) があります。デフォルトは FULL または NORMAL です。パフォーマンス重視でクラッシュ時のリスクを許容できる場合は NORMALOFF を検討することもありますが、データの整合性が最も重要なら FULL を維持すべきです。
    • PRAGMA cache_size: メモリにキャッシュするページ数を指定します。デフォルトは小さい値なので、十分なメモリがある場合は増やすことでディスクI/Oを減らせる可能性があります。負の値を指定するとKB単位になります(例: -2048 は2MB)。
    • PRAGMA temp_store: 一時テーブルやインデックスの格納場所を設定します。DEFAULT (ファイル), FILE (ファイル), MEMORY (メモリ) があります。メモリ上に置くことで高速化できますが、利用可能なメモリ量に注意が必要です。

これらのPRAGMA設定は、接続文字列にパラメータとして渡すか、接続後に db.Exec で実行できます。

接続文字列の例:
go
db, err := sql.Open("sqlite3", "./my_database.db?_journal_mode=WAL&_synchronous=NORMAL&_cache_size=-10000") // キャッシュサイズを約10MBに設定

この形式は、go-sqlite3ドライバー特有のものです。他のデータベースドライバーとは異なる場合があるので、ドライバーのドキュメントを確認してください。

PRAGMA文の例:
go
_, err := db.Exec("PRAGMA journal_mode=WAL;")
if err != nil { log.Fatal(err) }
_, err = db.Exec("PRAGMA synchronous=NORMAL;")
if err != nil { log.Fatal(err) }

これらは接続確立直後に実行する必要があります。

SQLite3の制限と注意点

SQLite3は多くの利点がありますが、その性質上、制限事項や注意点も存在します。

  • 並行書き込みの制限: 前述の通り、WALモードでも複数の書き込み処理を完全に並列化することはできません。書き込みが多いアプリケーションではボトルネックになります。
  • スケーラビリティ: 単一のデータベースファイルに依存するため、データ量が極端に大きくなったり、同時アクセス数が非常に多くなったりすると、パフォーマンスが劣化したり、管理が困難になったりします。分散システムやシャーディングには対応していません。
  • データ型の柔軟性: SQLite3の型アフィニティは柔軟ですが、これは意図しない型のデータが挿入される可能性があることを意味します。厳密な型チェックが必要な場合は、アプリケーション側でのバリデーションや、STRICT テーブル (CREATE TABLE ... STRICT;) の利用を検討する必要があります(ただし、STRICT テーブルは比較的新しい機能です)。
  • クライアント・サーバー機能がない: ネットワーク越しにデータベースにアクセスするための組み込み機能はありません。リモートからアクセスするには、アプリケーション自身がAPIエンドポイントなどを提供する必要があります。
  • 高度な管理機能の欠如: ユーザー管理、権限設定、レプリケーション、自動バックアップ、クラスタリングなど、エンタープライズ級のデータベースが持つ多くの管理機能は提供されていません。

これらの制限を理解し、SQLite3がアプリケーションの要件に適しているかを判断することが重要です。

テスト

データベース操作を含むコードはテストが難しい場合があります。外部サービス(データベース)への依存があるためです。テストを独立させ、高速に実行するために、SQLite3の インメモリデータベース を活用できます。

インメモリデータベースはファイルではなくメモリ上に作成され、接続が閉じられるとデータは失われます。これにより、テストごとにクリーンな状態のデータベースを準備することが容易になります。

インメモリデータベースは、接続文字列に :memory: または file::memory:?cache=shared を指定して作成します。?cache=shared を付けると、複数の接続で同じインメモリデータベースを共有できます。テスト関数内で複数のデータベース操作を行う場合や、複数のテスト関数で同じデータベース構造を使用したい場合に便利です。

テストにおける一般的なパターンは以下の通りです。

  1. テストヘルパー関数でインメモリデータベースを作成し、接続を返す。
  2. その接続を使って、テストに必要なテーブル構造を作成する。
  3. テスト対象の関数を実行する。
  4. 実行結果をデータベースから読み取り、期待される結果と比較する。
  5. テスト終了時にデータベース接続を閉じる(メモリ上のデータベースは消滅する)。

“`go
// db_test.go
package main

import (
“database/sql”
“testing”

_ "github.com/mattn/go-sqlite3"

)

// テスト用のインメモリDBを作成し、テーブルをセットアップするヘルパー関数
func setupTestDB(t testing.T) (sql.DB, error) {
// ?cache=shared をつけると、同じプロセス内の複数の接続でこのDBを共有できる
db, err := sql.Open(“sqlite3”, “file::memory:?cache=shared”)
if err != nil {
return nil, err
}

// テスト用のテーブルを作成(実際のアプリケーションのテーブル定義をコピー)
createSQL := `
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT UNIQUE,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);`
_, err = db.Exec(createSQL)
if err != nil {
    db.Close()
    return nil, err
}

return db, nil

}

func TestInsertAndGetUser(t *testing.T) {
db, err := setupTestDB(t)
if err != nil {
t.Fatalf(“テストDBのセットアップに失敗: %v”, err)
}
defer db.Close() // テスト終了時にDB接続を閉じる

// Insertテスト
name := "Test User"
email := "[email protected]"
id, err := insertUser(db, name, email) // 前述のinsertUser関数を呼び出す
if err != nil {
    t.Fatalf("ユーザー挿入に失敗: %v", err)
}
if id <= 0 {
    t.Errorf("期待したIDが取得できませんでした: %d", id)
}

// Getテスト
user, err := getUserByID(db, int(id)) // 前述のgetUserByID関数を呼び出す
if err != nil {
    t.Fatalf("ユーザー取得に失敗: %v", err)
}
if user == nil {
    t.Fatal("挿入したユーザーが見つかりません")
}
if user.Name != name || user.Email != email {
    t.Errorf("取得したユーザー情報が期待と異なります。期待: %s, %s, 取得: %s, %s",
        name, email, user.Name, user.Email)
}

// 存在しないユーザーの取得テスト
notFoundUser, err := getUserByID(db, 999)
if err != nil {
    t.Fatalf("存在しないユーザー取得時にエラー: %v", err)
}
if notFoundUser != nil {
    t.Errorf("存在しないはずのユーザーが見つかりました: %+v", notFoundUser)
}

}

// 他のテスト関数も同様に setupTestDB を呼び出す
“`

さらに、テスト関数内でトランザクションを開始し、テストの最後に必ずロールバックするというパターンも有効です。これにより、各テストがデータベースに永続的な変更を加えず、他のテストに影響を与えないようにできます。

“`go
func TestTransactionUpdate(t *testing.T) {
db, err := setupTestDB(t)
if err != nil {
t.Fatalf(“テストDBのセットアップに失敗: %v”, err)
}
defer db.Close()

// テストデータの挿入
id, err := insertUser(db, "Temp User", "[email protected]")
if err != nil {
    t.Fatalf("テストデータ挿入失敗: %v", err)
}

// テスト用のトランザクションを開始
tx, err := db.BeginTx(context.Background(), nil)
if err != nil {
    t.Fatalf("トランザクション開始失敗: %v", err)
}
defer tx.Rollback() // テスト終了時に必ずロールバック

// トランザクション内での更新操作 (例: updateEmailTx という関数があるとして)
// func updateEmailTx(tx *sql.Tx, id int, newEmail string) error { ... }
// err = updateEmailTx(tx, int(id), "[email protected]")
// if err != nil {
//     t.Fatalf("トランザクション内更新失敗: %v", err)
// }

// ここでは簡単なtx.Execの例で代用
updateSQL := `UPDATE users SET email = ? WHERE id = ?;`
_, err = tx.Exec(updateSQL, "[email protected]", id)
if err != nil {
    t.Fatalf("トランザクション内更新失敗: %v", err)
}

// トランザクションがコミットされないまま終了するので、変更は永続化されない
// テストでコミットの成功/失敗を確認する場合は、tx.Commit()を明示的に呼び出し、エラーをチェックする

// このテスト関数が終了すると defer tx.Rollback() が実行され、更新は元に戻る

}
“`

このロールバック戦略により、各テストは独立して実行でき、テストの順序に依存しなくなります。

Goフレームワークとの連携とORM

Webアプリケーションフレームワーク(Echo, Gin, Fiberなど)を使用する場合も、SQLite3は通常のデータベースとして database/sql または sqlx を介して利用できます。データベース接続は通常、アプリケーションの起動時に確立され、フレームワークのコンテキストやDI(依存性注入)コンテナを通じて各ハンドラやサービスに渡されます。

より高度なデータベース操作や、データベースとのやり取りを抽象化したい場合は、ORM (Object-Relational Mapper) を検討できます。ORMはデータベースのテーブルとGoの構造体をマッピングし、SQLを書かずにGoのコードでデータベース操作を行えるようにします。

Goで人気のORM(SQLite3対応)には以下があります。

  • GORM: 広く利用されており、豊富な機能を持ちます。構造体の定義とメソッド呼び出しでCRUD操作や関連付け、マイグレーションなどが行えます。
  • SQLBoiler: データベーススキーマからGoのコードを生成するアプローチをとります。型安全性が高く、パフォーマンスが良いのが特徴です。
  • sqlc: SQLクエリをGoのコードに変換するツールです。SQLを書く必要はありますが、型安全なインターフェースを自動生成できます。
  • Bun: 新しいORMで、パフォーマンスと使いやすさを両立することを目指しています。

これらのORMはそれぞれ異なる哲学や機能セットを持っています。プロジェクトの規模、チームの好み、パフォーマンス要件などを考慮して選択します。ORMはデータベース操作を容易にする反面、隠れたSQLが実行されることによるデバッグの難しさや、複雑なクエリを表現しにくいといったデメリットもあります。シンプルなアプリケーションやパフォーマンスが重視される部分では、database/sqlsqlx を直接使う方が適している場合もあります。

ベストプラクティス

GoでSQLite3を扱う上でのベストプラクティスをまとめます。

  • DB接続のライフサイクル管理: sql.Open で取得した *sql.DB オブジェクトは、アプリケーションのライフサイクルを通じて保持し、複数のゴルーチンで共有するのが一般的です。関数ごとに OpenClose を繰り返すべきではありません。deferを使用してアプリケーション終了時に Close を呼び出すようにします。
  • プリペアドステートメントの活用: SQLインジェクションを防ぎ、パフォーマンスを向上させるため、常にプリペアドステートメント(db.Prepare または ORM の機能)を使用します。頻繁に実行されるクエリは、db.Prepare でステートメントを準備しておき、それを繰り返し実行すると効率的です。
  • 適切なエラーハンドリング: データベース操作のエラーは適切に捕捉し、ログに記録したり、ユーザーに分かりやすい形で伝えたりします。特にトランザクション中のエラーでは必ずロールバックを実行します。
  • コネクションプールの設定: SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime をアプリケーションの同時実行性や利用パターンに合わせて適切に設定します。SQLite3の場合は、SetMaxOpenConns をシステムのCPUコア数や想定される並行クエリ数に合わせて設定するのが目安となります。
  • WALモードの検討: 書き込みと読み込みが同時に発生する可能性がある場合は、WALモードを有効にすることで並行性を向上できます。
  • データのバックアップ: 重要なデータを含むデータベースファイルは定期的にバックアップを取る必要があります。データベースファイルをコピーするだけでもバックアップになりますが、PRAGMA integrity_check でファイルの整合性を確認したり、PRAGMA backup_to コマンドでオンラインバックアップを行ったりすることも可能です。
  • ロギング: 実行されたSQLクエリやエラー情報をログに出力することで、デバッグやパフォーマンス分析が容易になります。database/sql にはクエリロギングの組み込み機能はありませんが、カスタムラッパーを作成したり、ORMのロギング機能を利用したりできます。

実践的なサンプルコード:シンプルなTODOアプリケーション

これまでの内容を応用して、シンプルなTODOリストアプリケーションのデータ層をSQLite3で実装する例を考えます。ユーザーごとのTODOリストを管理する機能を想定します。

“`go
// main.go (一部抜粋)
package main

import (
“database/sql”
“fmt”
“log”
“time”

_ "github.com/mattn/go-sqlite3"

)

// モデル(構造体)
type Todo struct {
ID int json:"id"
UserID int json:"user_id"
Title string json:"title"
Completed bool json:"completed"
CreatedAt time.Time json:"created_at"
}

// リポジトリパターンを模倣したインターフェースと実装
type TodoRepository interface {
CreateTodo(userID int, title string) (*Todo, error)
GetTodosByUserID(userID int) ([]Todo, error)
MarkTodoCompleted(todoID int, userID int) error
DeleteTodo(todoID int, userID int) error
}

type sqliteTodoRepository struct {
db *sql.DB
}

func NewSqliteTodoRepository(db *sql.DB) TodoRepository {
return &sqliteTodoRepository{db: db}
}

func (r sqliteTodoRepository) CreateTodo(userID int, title string) (Todo, error) {
insertSQL := INSERT INTO todos (user_id, title, completed) VALUES (?, ?, ?);
result, err := r.db.Exec(insertSQL, userID, title, false)
if err != nil {
return nil, fmt.Errorf(“TODO挿入に失敗: %w”, err)
}

id, err := result.LastInsertId()
if err != nil {
    return nil, fmt.Errorf("挿入されたTODOのID取得に失敗: %w", err)
}

// 挿入したTODOを取得(作成日時などを正確に知るため)
return r.getTodoByID(int(id)) // ヘルパーメソッド

}

func (r sqliteTodoRepository) getTodoByID(todoID int) (Todo, error) {
selectSQL := SELECT id, user_id, title, completed, created_at FROM todos WHERE id = ?;
row := r.db.QueryRow(selectSQL, todoID)

var todo Todo
var completedInt int // SQLiteのBOOLEANはINTEGERで保存されることが多い
var createdAtStr string

err := row.Scan(&todo.ID, &todo.UserID, &todo.Title, &completedInt, &createdAtStr)
if err != nil {
    if err == sql.ErrNoRows {
        return nil, nil // 見つからない
    }
    return nil, fmt.Errorf("TODO取得に失敗: %w", err)
}

// INTEGER (0 or 1) を bool に変換
todo.Completed = (completedInt != 0)

// DATETIME文字列を time.Time にパース
// SQLiteのDEFAULT CURRENT_TIMESTAMPは "YYYY-MM-DD HH:MM:SS" 形式
parsedTime, err := time.Parse("2006-01-02 15:04:05", createdAtStr)
if err != nil {
    // パースエラーは致命的ではないかもしれないが、ログなどで警告すべき
    log.Printf("Warning: Failed to parse created_at time string '%s': %v", createdAtStr, err)
    // エラーとしては返さないでおくことも
    todo.CreatedAt = time.Time{} // Zero value
} else {
    todo.CreatedAt = parsedTime
}

return &todo, nil

}

func (r *sqliteTodoRepository) GetTodosByUserID(userID int) ([]Todo, error) {
selectSQL := SELECT id, user_id, title, completed, created_at FROM todos WHERE user_id = ? ORDER BY created_at DESC;
rows, err := r.db.Query(selectSQL, userID)
if err != nil {
return nil, fmt.Errorf(“ユーザーID %d のTODO取得に失敗: %w”, userID, err)
}
defer rows.Close()

var todos []Todo
for rows.Next() {
    var todo Todo
    var completedInt int
    var createdAtStr string
    if err := rows.Scan(&todo.ID, &todo.UserID, &todo.Title, &completedInt, &createdAtStr); err != nil {
        return nil, fmt.Errorf("TODOスキャンに失敗: %w", err)
    }
    todo.Completed = (completedInt != 0)
    parsedTime, err := time.Parse("2006-01-02 15:04:05", createdAtStr)
    if err != nil {
        log.Printf("Warning: Failed to parse created_at time string '%s': %v", createdAtStr, err)
        todo.CreatedAt = time.Time{}
    } else {
        todo.CreatedAt = parsedTime
    }
    todos = append(todos, todo)
}

if err := rows.Err(); err != nil {
    return nil, fmt.Errorf("TODO行処理エラー: %w", err)
}

return todos, nil

}

func (r *sqliteTodoRepository) MarkTodoCompleted(todoID int, userID int) error {
// ユーザーIDも条件に含めることで、他のユーザーのTODOを勝手に完了できないようにする
updateSQL := UPDATE todos SET completed = ? WHERE id = ? AND user_id = ?;
result, err := r.db.Exec(updateSQL, true, todoID, userID) // true は SQLite では 1 に変換される
if err != nil {
return fmt.Errorf(“TODO %d (ユーザー %d) の完了更新に失敗: %w”, todoID, userID, err)
}

rowsAffected, err := result.RowsAffected()
if err != nil {
    return fmt.Errorf("TODO %d (ユーザー %d) の更新結果取得に失敗: %w", todoID, userID, err)
}
if rowsAffected == 0 {
    // 該当TODOが見つからないか、ユーザーIDが一致しない場合
    return fmt.Errorf("TODO %d が見つからないか、ユーザーID %d と一致しません", todoID, userID)
}

return nil

}

func (r *sqliteTodoRepository) DeleteTodo(todoID int, userID int) error {
// ユーザーIDも条件に含める
deleteSQL := DELETE FROM todos WHERE id = ? AND user_id = ?;
result, err := r.db.Exec(deleteSQL, todoID, userID)
if err != nil {
return fmt.Errorf(“TODO %d (ユーザー %d) の削除に失敗: %w”, todoID, userID, err)
}

rowsAffected, err := result.RowsAffected()
if err != nil {
    return fmt.Errorf("TODO %d (ユーザー %d) の削除結果取得に失敗: %w", todoID, userID, err)
}
if rowsAffected == 0 {
    return fmt.Errorf("TODO %d が見つからないか、ユーザーID %d と一致しません", todoID, userID)
}

return nil

}

func main() {
dbFile := “./todo_app.db”
db, err := sql.Open(“sqlite3″, dbFile+”?_journal_mode=WAL”)
if err != nil {
log.Fatal(err)
}
defer db.Close()

// TODOテーブルを作成(マイグレーションツールの代わりにシンプルなCREATE IF NOT EXISTS)
createTableSQL := `
CREATE TABLE IF NOT EXISTS todos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    title TEXT NOT NULL,
    completed BOOLEAN DEFAULT FALSE,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) -- usersテーブルがある前提
);`
_, err = db.Exec(createTableSQL)
if err != nil {
    log.Fatalf("TODOテーブル作成に失敗: %v", err)
}

// usersテーブルも必要(簡単のため別途作成したと仮定。またはここで作成)
// CREATE TABLE IF NOT EXISTS users (...); INSERT INTO users (name) VALUES ('Test User');

// リポジトリを作成
todoRepo := NewSqliteTodoRepository(db)

// サンプル操作
testUserID := 1 // 仮のユーザーID

// TODO作成
todo1, err := todoRepo.CreateTodo(testUserID, "牛乳を買う")
if err != nil {
    log.Fatalf("TODO作成失敗: %v", err)
}
log.Printf("作成したTODO: %+v", todo1)

todo2, err := todoRepo.CreateTodo(testUserID, "パンを予約する")
if err != nil {
    log.Fatalf("TODO作成失敗: %v", err)
}
log.Printf("作成したTODO: %+v", todo2)

// TODO一覧取得
todos, err := todoRepo.GetTodosByUserID(testUserID)
if err != nil {
    log.Fatalf("TODO一覧取得失敗: %v", err)
}
log.Printf("TODO一覧 (%d件):", len(todos))
for _, t := range todos {
    log.Printf("- ID: %d, Title: %s, Completed: %t", t.ID, t.Title, t.Completed)
}

// TODO完了マーク
if todo1 != nil {
    err = todoRepo.MarkTodoCompleted(todo1.ID, testUserID)
    if err != nil {
        log.Fatalf("TODO完了マーク失敗: %v", err)
    }
    log.Printf("TODO %d を完了にしました。", todo1.ID)
}

// TODO削除
if todo2 != nil {
    err = todoRepo.DeleteTodo(todo2.ID, testUserID)
    if err != nil {
        log.Fatalf("TODO削除失敗: %v", err)
    }
    log.Printf("TODO %d を削除しました。", todo2.ID)
}

// 変更後のTODO一覧取得
remainingTodos, err := todoRepo.GetTodosByUserID(testUserID)
if err != nil {
    log.Fatalf("変更後のTODO一覧取得失敗: %v", err)
}
log.Printf("変更後のTODO一覧 (%d件):", len(remainingTodos))
for _, t := range remainingTodos {
    log.Printf("- ID: %d, Title: %s, Completed: %t", t.ID, t.Title, t.Completed)
}

}
“`

このサンプルでは、データアクセスロジックを TodoRepository インターフェースとその実装クラスに分離する「リポジトリパターン」を模倣しています。これにより、データアクセス層とビジネスロジック層の間に抽象化レイヤーができます。テストもしやすくなり(インターフェースに対してモックを作成するなど)、将来的にデータベースを変更する際も影響範囲を限定できます。

created_at のように、SQLiteのDATETIME型(実際はTEXTとして保存されることが多い)をGoの time.Time に変換する処理は、取得時に手動で行う必要があります。または、sqlx やORMを利用して自動化することも可能です。

まとめ

この記事では、Go言語でSQLite3を効果的に活用するための様々なテクニックとベストプラクティスを詳細に解説しました。

  • SQLite3がGoアプリケーションに適している理由(軽量、サーバーレス、手軽さ)。
  • database/sqlgo-sqlite3 を使った基本的なCRUD操作。
  • データの整合性を保つためのトランザクション管理。
  • 並行処理を考慮した接続プールの設定とWALモードの活用。
  • sqlx を使った構造体との効率的なマッピング。
  • データベーススキーマの管理(マイグレーション)。
  • インデックスやPRAGMA文によるパフォーマンス最適化。
  • SQLite3の制限と注意点。
  • インメモリデータベースを使ったテスト方法。
  • ORMやフレームワークとの連携の可能性。
  • 堅牢なアプリケーション開発のためのベストプラクティス。
  • リポジトリパターンを取り入れた実践的なTODOアプリケーションのサンプルコード。

SQLite3はシンプルながらも強力なデータベースエンジンであり、Go言語との組み合わせで多くのアプリケーション開発シナリオにおいて優れた選択肢となります。この記事で得た知識を活用し、あなたのGoアプリケーション開発に役立ててください。

Happy Coding!


コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール