這是 Go 語言從零到 Web 應用系列的第八篇。上一篇我們建立了結構清晰的專案,但資料存在記憶體中。這篇要把資料存進真正的資料庫。

為什麼選 SQLite?

對於教學和小型應用來說,SQLite 是完美的選擇:

  • 零配置:不需要安裝資料庫伺服器
  • 單一檔案:整個資料庫就是一個檔案
  • 效能優秀:對於大多數應用場景都足夠快
  • SQL 相容:學到的 SQL 知識可以直接用在 PostgreSQL、MySQL

而且 Go 的 database/sql 是統一的資料庫介面,只要換一個 driver 就能切換到其他資料庫。

database/sql 介紹

database/sql 是 Go 標準庫提供的資料庫抽象層。它定義了統一的介面,不同的資料庫只需要提供對應的 driver。

常見的 driver:

  • SQLite: github.com/mattn/go-sqlite3(CGO)或 modernc.org/sqlite(純 Go)
  • PostgreSQL: github.com/lib/pqgithub.com/jackc/pgx
  • MySQL: github.com/go-sql-driver/mysql

我們使用純 Go 實作的 SQLite driver,不需要 CGO:

go get modernc.org/sqlite

連接資料庫

import (
    "database/sql"
    _ "modernc.org/sqlite"  // 註冊 SQLite driver
)

func main() {
    db, err := sql.Open("sqlite", "./bookapi.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 驗證連線
    if err := db.Ping(); err != nil {
        log.Fatal(err)
    }

    fmt.Println("Database connected!")
}

注意 _ "modernc.org/sqlite" 這行——前綴的 _ 表示只引入套件的副作用(註冊 driver),不直接使用它的任何匯出名稱。

連線池設定

sql.DB 不是單一連線,而是一個連線池。可以調整池的參數:

db.SetMaxOpenConns(25)                  // 最大開啟連線數
db.SetMaxIdleConns(5)                   // 最大閒置連線數
db.SetConnMaxLifetime(5 * time.Minute)  // 連線最大存活時間

Migration(資料庫遷移)

Migration 是管理資料庫 schema 變更的機制。

簡單的 Migration 實作

func migrate(db *sql.DB) error {
    query := `
    CREATE TABLE IF NOT EXISTS books (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        author TEXT NOT NULL,
        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
    );

    CREATE INDEX IF NOT EXISTS idx_books_author ON books(author);
    `

    _, err := db.Exec(query)
    return err
}

main 中啟動時執行:

if err := migrate(db); err != nil {
    log.Fatalf("Migration failed: %v", err)
}

對於更複雜的專案,可以使用專門的 migration 工具如 golang-migrate/migrate,但在教學階段,簡單的 CREATE TABLE IF NOT EXISTS 就夠用了。

CRUD 操作

Create(新增)

func createBook(db *sql.DB, title, author string) (int64, error) {
    result, err := db.Exec(
        "INSERT INTO books (title, author) VALUES (?, ?)",
        title, author,
    )
    if err != nil {
        return 0, fmt.Errorf("creating book: %w", err)
    }

    id, err := result.LastInsertId()
    if err != nil {
        return 0, fmt.Errorf("getting last insert id: %w", err)
    }

    return id, nil
}

Read(讀取)

讀取單筆:

func getBook(db *sql.DB, id int) (model.Book, error) {
    var book model.Book

    err := db.QueryRow(
        "SELECT id, title, author, created_at, updated_at FROM books WHERE id = ?",
        id,
    ).Scan(&book.ID, &book.Title, &book.Author, &book.CreatedAt, &book.UpdatedAt)

    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return book, ErrNotFound
        }
        return book, fmt.Errorf("querying book %d: %w", id, err)
    }

    return book, nil
}

QueryRow 回傳最多一行結果。如果沒有匹配的資料,Scan 會回傳 sql.ErrNoRows

讀取多筆:

func listBooks(db *sql.DB) ([]model.Book, error) {
    rows, err := db.Query(
        "SELECT id, title, author, created_at, updated_at FROM books ORDER BY id DESC",
    )
    if err != nil {
        return nil, fmt.Errorf("querying books: %w", err)
    }
    defer rows.Close()

    var books []model.Book
    for rows.Next() {
        var b model.Book
        if err := rows.Scan(&b.ID, &b.Title, &b.Author, &b.CreatedAt, &b.UpdatedAt); err != nil {
            return nil, fmt.Errorf("scanning book: %w", err)
        }
        books = append(books, b)
    }

    // 檢查迭代過程中是否有錯誤
    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("iterating rows: %w", err)
    }

    return books, nil
}

重要:永遠要 defer rows.Close(),否則會造成連線洩漏。

Update(更新)

func updateBook(db *sql.DB, id int, title, author string) error {
    result, err := db.Exec(
        "UPDATE books SET title = ?, author = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
        title, author, id,
    )
    if err != nil {
        return fmt.Errorf("updating book %d: %w", id, err)
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("getting rows affected: %w", err)
    }

    if rowsAffected == 0 {
        return ErrNotFound
    }

    return nil
}

Delete(刪除)

func deleteBook(db *sql.DB, id int) error {
    result, err := db.Exec("DELETE FROM books WHERE id = ?", id)
    if err != nil {
        return fmt.Errorf("deleting book %d: %w", id, err)
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("getting rows affected: %w", err)
    }

    if rowsAffected == 0 {
        return ErrNotFound
    }

    return nil
}

SQL Injection 防護

永遠使用參數化查詢,不要拼接 SQL 字串:

// 危險!SQL Injection 漏洞
query := "SELECT * FROM books WHERE title = '" + userInput + "'"

// 安全!使用參數化查詢
db.Query("SELECT * FROM books WHERE title = ?", userInput)

? 佔位符會被 driver 正確地 escape,防止 SQL Injection。

不同資料庫的佔位符格式:

  • SQLite / MySQL: ?
  • PostgreSQL: $1, $2, $3

Repository Pattern

將資料庫操作封裝成 Repository,讓 handler 不直接碰 SQL:

internal/store/book.go(改用資料庫版本)

package store

import (
    "bookapi/internal/model"
    "database/sql"
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found")

type BookStore struct {
    db *sql.DB
}

func NewBookStore(db *sql.DB) *BookStore {
    return &BookStore{db: db}
}

func (s *BookStore) List() ([]model.Book, error) {
    rows, err := s.db.Query(
        "SELECT id, title, author, created_at, updated_at FROM books ORDER BY id DESC",
    )
    if err != nil {
        return nil, fmt.Errorf("querying books: %w", err)
    }
    defer rows.Close()

    var books []model.Book
    for rows.Next() {
        var b model.Book
        if err := rows.Scan(&b.ID, &b.Title, &b.Author, &b.CreatedAt, &b.UpdatedAt); err != nil {
            return nil, fmt.Errorf("scanning book: %w", err)
        }
        books = append(books, b)
    }

    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("iterating rows: %w", err)
    }

    return books, nil
}

func (s *BookStore) GetByID(id int) (model.Book, error) {
    var book model.Book
    err := s.db.QueryRow(
        "SELECT id, title, author, created_at, updated_at FROM books WHERE id = ?",
        id,
    ).Scan(&book.ID, &book.Title, &book.Author, &book.CreatedAt, &book.UpdatedAt)

    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return book, ErrNotFound
        }
        return book, fmt.Errorf("querying book %d: %w", id, err)
    }

    return book, nil
}

func (s *BookStore) Create(req model.CreateBookRequest) (model.Book, error) {
    result, err := s.db.Exec(
        "INSERT INTO books (title, author) VALUES (?, ?)",
        req.Title, req.Author,
    )
    if err != nil {
        return model.Book{}, fmt.Errorf("inserting book: %w", err)
    }

    id, err := result.LastInsertId()
    if err != nil {
        return model.Book{}, fmt.Errorf("getting last insert id: %w", err)
    }

    return s.GetByID(int(id))
}

func (s *BookStore) Update(id int, req model.UpdateBookRequest) (model.Book, error) {
    book, err := s.GetByID(id)
    if err != nil {
        return book, err
    }

    if req.Title != nil {
        book.Title = *req.Title
    }
    if req.Author != nil {
        book.Author = *req.Author
    }

    _, err = s.db.Exec(
        "UPDATE books SET title = ?, author = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
        book.Title, book.Author, id,
    )
    if err != nil {
        return model.Book{}, fmt.Errorf("updating book %d: %w", id, err)
    }

    return s.GetByID(id)
}

func (s *BookStore) Delete(id int) error {
    result, err := s.db.Exec("DELETE FROM books WHERE id = ?", id)
    if err != nil {
        return fmt.Errorf("deleting book %d: %w", id, err)
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("getting rows affected: %w", err)
    }

    if rowsAffected == 0 {
        return ErrNotFound
    }

    return nil
}

更新 main.go

func main() {
    // 連接資料庫
    db, err := sql.Open("sqlite", "./bookapi.db")
    if err != nil {
        log.Fatalf("Opening database: %v", err)
    }
    defer db.Close()

    // 執行 migration
    if err := migrate(db); err != nil {
        log.Fatalf("Migration: %v", err)
    }

    // 初始化 store(現在接受 *sql.DB)
    bookStore := store.NewBookStore(db)

    // 其餘和之前一樣...
}

Handler 層的程式碼完全不需要改動——只是 store 內部從記憶體換成了資料庫。這就是 Repository Pattern 的好處。

交易(Transactions)

當多個操作需要原子性執行時,使用 transaction:

func (s *BookStore) TransferBooks(fromAuthor, toAuthor string) error {
    tx, err := s.db.Begin()
    if err != nil {
        return fmt.Errorf("beginning transaction: %w", err)
    }
    // 確保 transaction 最終被 commit 或 rollback
    defer tx.Rollback()

    // 在 transaction 中執行操作
    _, err = tx.Exec(
        "UPDATE books SET author = ? WHERE author = ?",
        toAuthor, fromAuthor,
    )
    if err != nil {
        return fmt.Errorf("updating author: %w", err)
    }

    // 提交 transaction
    if err := tx.Commit(); err != nil {
        return fmt.Errorf("committing transaction: %w", err)
    }

    return nil
}

defer tx.Rollback() 是安全的——如果已經 Commit 成功,Rollback 會是 no-op。

Context 與資料庫

在 Web 應用中,每個請求都有 context,應該傳遞給資料庫操作:

func (s *BookStore) GetByID(ctx context.Context, id int) (model.Book, error) {
    var book model.Book
    err := s.db.QueryRowContext(ctx,
        "SELECT id, title, author, created_at, updated_at FROM books WHERE id = ?",
        id,
    ).Scan(&book.ID, &book.Title, &book.Author, &book.CreatedAt, &book.UpdatedAt)

    // ...
}

// Handler 中傳遞 context
func (h *BookHandler) Get(w http.ResponseWriter, r *http.Request) {
    // r.Context() 在請求取消時會自動 cancel
    book, err := h.store.GetByID(r.Context(), id)
    // ...
}

使用 QueryRowContextQueryContextExecContext 而非不帶 context 的版本。這樣當客戶端斷線時,長時間運行的查詢會被自動取消。

常見陷阱

忘記關閉 Rows

// 錯誤:沒有 Close
rows, _ := db.Query("SELECT ...")
for rows.Next() { ... }

// 正確:用 defer Close
rows, err := db.Query("SELECT ...")
if err != nil { return err }
defer rows.Close()

Scan 的型別不匹配

// 資料庫欄位可能是 NULL
var name sql.NullString
row.Scan(&name)

if name.Valid {
    fmt.Println(name.String)
}

在迴圈中開啟 Rows 沒關閉

// 危險:每次迴圈都開一個 rows 但沒關
for _, id := range ids {
    rows, _ := db.Query("SELECT ...", id)
    // 用 QueryRow 替代,或確保每次都 Close
}

// 更好:用 QueryRow 或批次查詢
for _, id := range ids {
    var book model.Book
    db.QueryRow("SELECT ... WHERE id = ?", id).Scan(...)
}

下一步

我們的 Web 應用現在有了持久化儲存。下一篇要加入使用者認證——讓 API 能識別「誰」在操作,並保護需要登入才能存取的端點。