第 2 章:連線資料庫與 Migration(建表)

第 2 章:連線資料庫與 Migration(建表)

這一章在做什麼? 我們要用 sqlx 連上第 1 章跑在容器裡的 PostgreSQL,定義對應資料表的 Go struct(User / Account / Holding / Order),然後自己寫一支小型 migrator:把 migrations/ 裡的 .sql 檔依序套用、並用一張表記錄「哪些已經跑過」,避免重複執行。

為什麼自己寫 migrator? 因為它能讓你看清「migration 到底在做什麼」——其實就是「按順序執行一批 SQL,並記住跑到哪了」。全程只用 go run,不必另外安裝任何 CLI 工具。等你懂了原理,將來要換成 golang-migrate 之類的工具也只是換個外殼。

什麼是 migration? 把「資料庫結構的每一次變更」寫成一個一個有編號的 .sql 檔(建表、加欄位、加索引…),照順序套用。好處是:每個人、每個環境跑同一批檔案,就會得到一模一樣的資料庫結構,而且有版本可追。 🔁 JS 對照:就像 Knex / TypeORM / Prisma 的 migration。我們這裡是手寫一個極簡版,看清骨架。


步驟 1:安裝 sqlx 與 PostgreSQL 驅動

go get github.com/jmoiron/sqlx
go get github.com/lib/pq
  • github.com/jmoiron/sqlx:在 Go 標準庫 database/sql 之上加了一層便利功能 —— 最重要的是自動把查詢結果塞進 struct(不用一欄一欄手動 rows.Scan)。
  • github.com/lib/pq:PostgreSQL 的驅動(driver)。Go 的 database/sql 本身不認得任何特定資料庫,要靠驅動。

🔁 JS 對照 sqlx 大概介於 Node 的 pg(純驅動)和 ORM(如 Prisma)之間:你還是寫 SQL,但它幫你把結果對應到型別。lib/pqpg 套件本身。


步驟 2:定義資料模型(struct)

先把四張表對應的 Go struct 寫好。建立 internal/model/model.go

// internal/model/model.go
package model

import "time"

// User 對應 users 表
type User struct {
	ID           int64     `db:"id" json:"id"`
	Email        string    `db:"email" json:"email"`
	PasswordHash string    `db:"password_hash" json:"-"` // json:"-" 代表「永遠不要輸出到 JSON」
	CreatedAt    time.Time `db:"created_at" json:"created_at"`
}

// Account 對應 accounts 表(每個 user 一個帳戶)
type Account struct {
	ID          int64     `db:"id" json:"id"`
	UserID      int64     `db:"user_id" json:"user_id"`
	CashBalance int64     `db:"cash_balance" json:"cash_balance"` // 現金餘額,單位:分
	CreatedAt   time.Time `db:"created_at" json:"created_at"`
	UpdatedAt   time.Time `db:"updated_at" json:"updated_at"`
}

// Holding 對應 holdings 表(某帳戶持有某股票)
type Holding struct {
	ID        int64     `db:"id" json:"id"`
	AccountID int64     `db:"account_id" json:"account_id"`
	Symbol    string    `db:"symbol" json:"symbol"`
	Quantity  int64     `db:"quantity" json:"quantity"`   // 持有股數
	AvgCost   int64     `db:"avg_cost" json:"avg_cost"`   // 每股平均成本,單位:分
	CreatedAt time.Time `db:"created_at" json:"created_at"`
	UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

// Order 對應 orders 表(一筆成交紀錄)
type Order struct {
	ID          int64     `db:"id" json:"id"`
	AccountID   int64     `db:"account_id" json:"account_id"`
	Symbol      string    `db:"symbol" json:"symbol"`
	Side        string    `db:"side" json:"side"`                 // "buy" 或 "sell"
	Quantity    int64     `db:"quantity" json:"quantity"`
	Price       int64     `db:"price" json:"price"`               // 每股成交價,單位:分
	TotalAmount int64     `db:"total_amount" json:"total_amount"` // 總額,單位:分
	Status      string    `db:"status" json:"status"`
	CreatedAt   time.Time `db:"created_at" json:"created_at"`
}

重點解釋:

  • `db:"id" json:"id"` 是 struct tag:附在欄位後面的「中繼資料字串」。
    • db:"email":告訴 sqlx,這個欄位對應資料表的 email 欄。
    • json:"email":告訴 Go 的 JSON 編碼器,輸出 JSON 時這個欄位叫 email(不寫的話會用 Go 欄位名 Email,開頭大寫不符 JSON 慣例)。
    • json:"-"特別重要- 代表「轉成 JSON 時完全忽略這個欄位」。我們用它確保 PasswordHash 永遠不會被回傳給前端
  • 金額為什麼用 int64 而且單位是「分」? 浮點數(float)算錢會有精度誤差(0.1 + 0.2 != 0.3 在很多語言都成立)。所以我們把所有金額存成整數的「分」:$1000 存成 100000。要顯示時再除以 100。第 4 章會詳細講。
  • time.Time:Go 標準庫的時間型別,對應 PostgreSQL 的 TIMESTAMPTZ

🔁 JS 對照 struct tag 很像 TypeScript 裝飾器或 ORM 的欄位設定:

class User {
  @Column({ name: "password_hash" })
  passwordHash: string;  // 但要另外處理「序列化時排除」
}

json:"-" 對應你在 Node 常做的「toJSON() 裡把 password 刪掉」或 @Exclude()。Go 把它變成一個簡單的 tag,少寫很多防呆程式碼。


步驟 3:寫資料庫連線

建立 internal/database/database.go

// internal/database/database.go
package database

import (
	"fmt"
	"time"

	"github.com/jmoiron/sqlx"
	_ "github.com/lib/pq" // 只為了「註冊」postgres 驅動,故用底線 import

	"no-logic-trade/internal/config"
)

// Connect 依設定連上 PostgreSQL,回傳一個連線池
func Connect(cfg *config.Config) (*sqlx.DB, error) {
	// 組出連線字串(DSN)
	dsn := fmt.Sprintf(
		"host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
		cfg.DBHost, cfg.DBPort, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBSSLMode,
	)

	// sqlx.Connect = 開啟連線 + 立刻 Ping 一次確認連得上
	db, err := sqlx.Connect("postgres", dsn)
	if err != nil {
		return nil, fmt.Errorf("連線資料庫失敗: %w", err)
	}

	// 連線池設定(可依需求調整)
	db.SetMaxOpenConns(25)                 // 最多同時開 25 條連線
	db.SetMaxIdleConns(25)                 // 閒置時保留 25 條備用
	db.SetConnMaxLifetime(5 * time.Minute) // 每條連線最長活 5 分鐘

	return db, nil
}

逐段解釋:

  • _ "github.com/lib/pq" —— 開頭那個底線 _ 是「只執行這個套件的初始化、但我不直接用它的任何東西」。lib/pq 在自己的初始化裡,會偷偷向 Go 的 database/sql 「註冊」一個叫 postgres 的驅動。所以下面 sqlx.Connect("postgres", dsn)"postgres" 才認得。如果不加這個 import,編譯時會抱怨 unknown driver "postgres"
  • DSN(連線字串)host=... port=... user=...,所有值都從第 1 章的 Config 來,也就是最終源自 .env。因為我們開發期 app 在本機、DB 在容器並對應到 localhost:5432,所以 host=localhost 就連得到。
  • sqlx.Connect:它做了兩件事 —— 開連線、並馬上 Ping 一次。所以這個函式成功回傳,就代表資料庫真的連得上(而不是延遲到第一次查詢才發現連不上)。
  • 連線池設定:Go 的 *sqlx.DB 其實是一個連線池,不是單一連線。這幾個設定控制池子大小與連線壽命。
  • 回傳 (*sqlx.DB, error):又是 Go 典型的「結果 + error」雙回傳(第 0 章步驟 7)。
  • fmt.Errorf("...: %w", err):把底層錯誤「包」進一個更有上下文的訊息裡。%w 是特殊動詞,會保留原始 error,之後可以用 errors.Is / errors.Unwrap 追查。

🔁 JS 對照

import { Pool } from "pg";
const pool = new Pool({ host, port, user, password, database, max: 25 });
await pool.query("SELECT 1"); // 確認連得上

*sqlx.DBpgPoolSetMaxOpenConnsPool({ max })%w 包裝錯誤 ≈ throw new Error("連線失敗", { cause: err })


步驟 4:寫四個 migration 的 .sql

migrations/ 資料夾裡建立四個檔案。檔名前面的編號很重要 —— migrator 會照檔名排序執行,所以要確保依賴順序正確(先有 users 才能被 accounts 參照)。

migrations/001_create_users.sql

-- migrations/001_create_users.sql
CREATE TABLE users (
    id            BIGSERIAL PRIMARY KEY,
    email         TEXT NOT NULL UNIQUE,
    password_hash TEXT NOT NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

migrations/002_create_accounts.sql

-- migrations/002_create_accounts.sql
CREATE TABLE accounts (
    id           BIGSERIAL PRIMARY KEY,
    user_id      BIGINT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
    cash_balance BIGINT NOT NULL DEFAULT 0,  -- 現金餘額,單位:分
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

migrations/003_create_holdings.sql

-- migrations/003_create_holdings.sql
CREATE TABLE holdings (
    id         BIGSERIAL PRIMARY KEY,
    account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    symbol     TEXT NOT NULL,
    quantity   BIGINT NOT NULL,           -- 持有股數
    avg_cost   BIGINT NOT NULL,           -- 每股平均成本,單位:分
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (account_id, symbol)            -- 同一帳戶同一檔股票只會有一列
);

migrations/004_create_orders.sql

-- migrations/004_create_orders.sql
CREATE TABLE orders (
    id           BIGSERIAL PRIMARY KEY,
    account_id   BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
    symbol       TEXT NOT NULL,
    side         TEXT NOT NULL CHECK (side IN ('buy', 'sell')),
    quantity     BIGINT NOT NULL CHECK (quantity > 0),
    price        BIGINT NOT NULL,           -- 每股成交價,單位:分
    total_amount BIGINT NOT NULL,           -- 總額(分)= price * quantity
    status       TEXT NOT NULL DEFAULT 'filled',
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_orders_account_id ON orders (account_id);
CREATE INDEX idx_orders_account_symbol ON orders (account_id, symbol);

幾個 SQL 設計重點:

  • BIGSERIAL PRIMARY KEY:自動遞增的主鍵(對應 Go 的 int64)。
  • REFERENCES users(id) ON DELETE CASCADE:外鍵約束。ON DELETE CASCADE 表示刪掉 user 時,連帶刪掉它的 account、holdings…。
  • UNIQUEaccounts.user_id 唯一(一人一帳戶);holdings(account_id, symbol) 組合唯一(同帳戶同股票只有一列,買進就累加數量、更新均價,第 6 章會用到)。
  • CHECK (side IN ('buy','sell')):資料庫層級擋掉非法值。即使程式有 bug 寫錯,DB 也會拒絕。
  • idx_orders_* 索引:第 8 章「依帳戶 / 依股票查交易紀錄」會用到,先建好。

第 4 章我們會再新增一個 005_create_transactions.sql(資金異動 ledger)。這正是 migration 的精神 —— 結構是隨著功能一個檔一個檔長出來的。


步驟 5:用 embed.sql 打包進程式

我們希望這些 .sql 不只在 go run 時讀得到,將來編譯成單一執行檔(第 9 章容器化)時也一起被打包進去,不必額外搬檔案。Go 1.16+ 內建的 embed 正是做這件事。

migrations/ 裡新增一個 migrations/embed.go

// migrations/embed.go
package migrations

import "embed"

// go:embed 指令會把符合樣式的檔案「嵌入」成程式的一部分。
// 注意:下面那行註解的格式是固定的,//go:embed 之間「不能有空格」。

//go:embed *.sql
var Files embed.FS

逐段解釋:

  • //go:embed *.sql:這是一個特殊的「編譯器指令」(不是普通註解)。它叫編譯器把跟這支 .go 檔同資料夾的所有 *.sql 嵌進編譯結果
  • var Files embed.FS:嵌入的檔案會變成這個 embed.FS(一個唯讀的虛擬檔案系統)。之後我們的 migrator 就從 Files.sql,而不是從硬碟。
  • 因為這檔案在 migrations/ 裡,它的 package 名是 migrations,外面用 migrations.Files 取用。

🔁 JS 對照 有點像打包工具(webpack/esbuild)把靜態資源 inline 進 bundle,讓你不用在執行時還去找檔案路徑。差別是 Go 把這個能力內建在語言層,一行指令搞定。 好處很實際:第 9 章把 app 編成一個執行檔丟進容器時,migration SQL 已經在執行檔裡了,不會發生「忘了複製 migrations 資料夾導致建表失敗」。


步驟 6:寫 migrator 本體

這是這一章的核心。建立 internal/database/migrate.go

// internal/database/migrate.go
package database

import (
	"fmt"
	"io/fs"
	"log"
	"sort"
	"strings"

	"github.com/jmoiron/sqlx"

	"no-logic-trade/migrations"
)

// Migrate 依序套用尚未套用過的 migration
func Migrate(db *sqlx.DB) error {
	// 1) 確保有一張表記錄「已套用的版本」
	_, err := db.Exec(`
		CREATE TABLE IF NOT EXISTS schema_migrations (
			version    TEXT PRIMARY KEY,
			applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
		)
	`)
	if err != nil {
		return fmt.Errorf("建立 schema_migrations 失敗: %w", err)
	}

	// 2) 讀出目前已套用過哪些版本,放進一個 set 方便查
	var appliedList []string
	if err := db.Select(&appliedList, "SELECT version FROM schema_migrations"); err != nil {
		return fmt.Errorf("讀取已套用版本失敗: %w", err)
	}
	applied := make(map[string]bool)
	for _, v := range appliedList {
		applied[v] = true
	}

	// 3) 從嵌入的檔案系統讀出所有 .sql,依檔名排序
	entries, err := fs.ReadDir(migrations.Files, ".")
	if err != nil {
		return fmt.Errorf("讀取 migrations 目錄失敗: %w", err)
	}
	var files []string
	for _, e := range entries {
		if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
			files = append(files, e.Name())
		}
	}
	sort.Strings(files) // 001_, 002_, 003_... 照順序

	// 4) 逐一套用「還沒套用過」的 migration
	for _, name := range files {
		if applied[name] {
			continue // 跑過了就跳過(這就是 migration 的冪等性)
		}

		content, err := fs.ReadFile(migrations.Files, name)
		if err != nil {
			return fmt.Errorf("讀取 %s 失敗: %w", name, err)
		}

		// 每個 migration 包在一筆「交易」裡:
		// 整個 SQL + 寫入版本紀錄,要嘛一起成功、要嘛一起取消。
		tx, err := db.Beginx()
		if err != nil {
			return fmt.Errorf("開始交易失敗: %w", err)
		}

		if _, err := tx.Exec(string(content)); err != nil {
			tx.Rollback() // 出錯就整筆取消
			return fmt.Errorf("套用 %s 失敗: %w", name, err)
		}
		if _, err := tx.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", name); err != nil {
			tx.Rollback()
			return fmt.Errorf("記錄 %s 版本失敗: %w", name, err)
		}
		if err := tx.Commit(); err != nil {
			return fmt.Errorf("提交 %s 失敗: %w", name, err)
		}

		log.Printf("✅ 已套用 migration: %s", name)
	}

	return nil
}

逐段解釋:

  1. schema_migrations:用來記住「跑過哪些檔」。CREATE TABLE IF NOT EXISTS 確保多跑幾次也不會出錯。
  2. db.Select(&appliedList, ...):sqlx 的便利方法,把查詢結果一次塞進一個 slice。這裡查出所有已套用的版本字串。make(map[string]bool) 建立一個集合,方便等下 applied[name] 快速判斷。
  3. fs.ReadDir / fs.ReadFile:從步驟 5 嵌入的 migrations.Files 讀檔,而不是硬碟。sort.Strings 確保依編號順序。
  4. 逐一套用
    • 已套用過(applied[name] 為 true)就 continue 跳過 → 這讓 migrate 可以安全地重複執行(冪等)。
    • db.Beginx() 開一筆交易(transaction),回傳一個 *sqlx.Tx
    • 在交易裡先 tx.Exec 跑這個 .sql,再 tx.Exec 把版本寫進 schema_migrations
    • 任何一步出錯就 tx.Rollback()(整筆退回,當作沒發生過);全部成功才 tx.Commit()(一次落地)。
    • PostgreSQL 的 DDL(建表等)也能放進交易,所以萬一某個 migration 跑到一半失敗,不會留下半套的表。(這是 PostgreSQL 的優點,MySQL 不一定有。)

🔁 JS 對照

const tx = await pool.connect();
try {
  await tx.query("BEGIN");
  await tx.query(sql);
  await tx.query("INSERT INTO schema_migrations ...");
  await tx.query("COMMIT");
} catch (e) {
  await tx.query("ROLLBACK");
  throw e;
}

概念完全一樣。差別是 Go 沒有 try/catch,所以每一步後面都要 if err != nil { tx.Rollback(); return ... }。第 6 章的下單會把這個交易模式用到極致(用 defer 讓 rollback 更優雅)。


步驟 7:在程式啟動時連線並跑 migration

把第 1 章的 cmd/api/main.go 更新成下面這樣(加上連線與 migration):

// cmd/api/main.go
package main

import (
	"log"
	"net/http"

	"github.com/gin-gonic/gin"

	"no-logic-trade/internal/config"
	"no-logic-trade/internal/database"
)

func main() {
	// 1) 載入設定
	cfg := config.Load()

	// 2) 連線資料庫
	db, err := database.Connect(cfg)
	if err != nil {
		log.Fatalf("資料庫連線失敗: %v", err)
	}
	defer db.Close() // main 結束前關閉連線池
	log.Println("✅ 資料庫連線成功")

	// 3) 跑 migration(建表)
	if err := database.Migrate(db); err != nil {
		log.Fatalf("migration 失敗: %v", err)
	}
	log.Println("✅ migration 完成")

	// 4) 建立 Gin 並註冊路由
	r := gin.Default()
	r.GET("/health", func(c *gin.Context) {
		c.JSON(http.StatusOK, gin.H{"status": "ok"})
	})

	// 5) 啟動服務
	addr := ":" + cfg.AppPort
	log.Printf("🚀 server 啟動於 http://localhost%s", addr)
	if err := r.Run(addr); err != nil {
		log.Fatalf("server 啟動失敗: %v", err)
	}
}

新增的重點:

  • defer db.Close()defer 會把這行「延後到目前函式(main)結束前才執行」。常用來收尾(關連線、關檔案)。因為 Go 沒有 try/finally,defer 就是它的 finally。寫在「拿到資源的下一行」最安全 —— 一眼就看到開了什麼、之後一定會關。
  • 啟動時自動跑 migration:對中小型專案很方便,部署一上線就確保表都建好。(大型團隊有時會把 migration 拆成獨立步驟,但原理相同。)
  • 注意 migration 是冪等的,所以重啟 app 不會重複建表。

🔁 JS 對照 defer db.Close()

try { /* ...用 db... */ }
finally { await db.end(); }

defer 寫法更貼近「宣告即收尾」,而且多個 defer 會以後進先出順序執行。


✅ 如何執行 / 驗證這一章成功

  1. 確認資料庫容器在跑(第 1 章):
docker compose up -d
  1. 啟動 app:
go run ./cmd/api

你應該在啟動 log 看到 migration 一條一條被套用:

✅ 資料庫連線成功
✅ 已套用 migration: 001_create_users.sql
✅ 已套用 migration: 002_create_accounts.sql
✅ 已套用 migration: 003_create_holdings.sql
✅ 已套用 migration: 004_create_orders.sql
✅ migration 完成
🚀 server 啟動於 http://localhost:8080

驗證 1:表真的建出來了

另開終端機,列出所有表:

docker compose exec db psql -U nolologic -d nolologic -c "\dt"

預期看到(順序可能不同):

              List of relations
 Schema |       Name        | Type  |   Owner
--------+-------------------+-------+-----------
 public | accounts          | table | nolologic
 public | holdings          | table | nolologic
 public | orders            | table | nolologic
 public | schema_migrations | table | nolologic
 public | users             | table | nolologic

看一張表的詳細結構:

docker compose exec db psql -U nolologic -d nolologic -c "\d orders"

預期會列出 id / account_id / symbol / side / quantity / price / total_amount / status / created_at 等欄位與兩個索引。

驗證 2:版本紀錄正確

docker compose exec db psql -U nolologic -d nolologic -c "SELECT * FROM schema_migrations;"

預期看到四筆已套用版本:

            version            |          applied_at
-------------------------------+-------------------------------
 001_create_users.sql          | 2026-...
 002_create_accounts.sql       | 2026-...
 003_create_holdings.sql       | 2026-...
 004_create_orders.sql         | 2026-...

驗證 3:migration 是冪等的

Ctrl + C 停掉 app,再 go run ./cmd/api 一次。這次 log 不會再出現「已套用 migration」字樣(因為四個版本都記錄過了),但 /health 一樣正常:

curl http://localhost:8080/health
# {"status":"ok"}

代表 migrator 正確地跳過了已套用的檔案。🎉


這一章你完成了什麼

  • 用 sqlx + lib/pq 連上容器裡的 PostgreSQL,理解了「底線 import 註冊驅動」「*sqlx.DB 是連線池」。
  • 用 struct tag(db / json)把資料表對應成 Go struct,並用 json:"-" 保護密碼欄位。
  • 決定用 int64(單位:分)存金額,避開浮點誤差。
  • embed.sql 打包進程式、寫出一支會記錄版本、用交易保證原子性、且冪等的 migrator。
  • 學到 Go 的 defer(≈ finally)與 sqlx 交易(Beginx / Exec / Commit / Rollback)—— 第 6 章下單會大用。

下一步 → 第 3 章:使用者註冊 / 登入(JWT) 我們會用 bcrypt 雜湊密碼、做出註冊與登入、登入後簽發 JWT,並寫一個「驗證 token」的中介層保護後續 API。同時會正式把 handler → service → repository 三層串起來。