第 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/pq≈pg套件本身。
步驟 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.DB≈pg的Pool。SetMaxOpenConns≈Pool({ 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…。UNIQUE:accounts.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
}
逐段解釋:
schema_migrations表:用來記住「跑過哪些檔」。CREATE TABLE IF NOT EXISTS確保多跑幾次也不會出錯。db.Select(&appliedList, ...):sqlx 的便利方法,把查詢結果一次塞進一個 slice。這裡查出所有已套用的版本字串。make(map[string]bool)建立一個集合,方便等下applied[name]快速判斷。fs.ReadDir/fs.ReadFile:從步驟 5 嵌入的migrations.Files讀檔,而不是硬碟。sort.Strings確保依編號順序。- 逐一套用:
- 已套用過(
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 章):
docker compose up -d
- 啟動 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 三層串起來。