🎯 什麼情境該想到我
當你「想用一個物件當某張表的閘道,把該表所有 SQL 集中管理」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:以一個物件作為整張資料表的閘道,該物件的方法涵蓋對這張表的所有查詢與異動,SQL 全部集中於此。
做法要點:
- 一個 Gateway 實例服務「一整張表」,方法如 find、update、insert、delete。
- 查詢通常回傳 Record Set(記錄集)或資料結構,供上層使用。
- 物件本身不含業務邏輯,只負責資料存取,職責單純。
- 常搭配 Table Module 使用(Table Module 拿記錄集做業務)。
★★ 這是 Go 生態的主流做法,而且多數人不知道它有名字
// ★ sqlc 產生的程式碼就是表格資料閘道
type Queries struct{ db DBTX }
func (q *Queries) GetOrder(ctx context.Context, id string) (Order, error)
func (q *Queries) ListOrdersByCustomer(ctx context.Context, customerID string) ([]Order, error)
func (q *Queries) InsertOrder(ctx context.Context, arg InsertOrderParams) (Order, error)
func (q *Queries) UpdateOrderStatus(ctx context.Context, arg UpdateOrderStatusParams) (int64, error)
// ★★ 特徵:一個型別、無狀態、每個方法對應一個 SQL、回傳扁平的 row struct★★
sqlc/sqlx/手寫的db包,本質上都是表格資料閘道。
它是 Go 社群壓倒性的預設選擇,理由很具體:
★ SQL 看得見(可 review、可EXPLAIN)、無隱形查詢、無 N+1、編譯期型別安全。
★ 為什麼它在 Go 裡勝過 主動記錄與資料對應器
| 表格資料閘道(sqlc) | 主動記錄(GORM) | 資料對應器(ent) | |
|---|---|---|---|
| SQL 可見 | ★★ 完全可見 | 隱藏 | 部分隱藏 |
| N+1 風險 | ★ 不可能(沒有延遲載入) | ★★ 高 | 中 |
| 型別安全 | ★★ codegen,編譯期 | ★ 反射+字串,執行期 | codegen |
| 領域模型解耦 | ★ 不解耦(row struct 就是資料形狀) | 不解耦 | ★ 解耦 |
| 學習成本 | ★ 低(就是 SQL) | 中 | ★ 高 |
| 複雜查詢 | ★★ 直接寫 SQL | ★ 難(要 Raw) | 中 |
★★ 關鍵取捨:表格資料閘道不幫你解耦領域模型。
Order 這個 row struct 的形狀就是資料表的形狀。
→ ★ 要領域模型就在它上面加一層 reconstitute(見 領域模型、資料對應器),
而不是換掉它。
★★ 交易怎麼傳:Go 的核心慣例
// ★ sqlc 的 DBTX 介面讓 *sql.DB 與 *sql.Tx 都能用
type DBTX interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
PrepareContext(context.Context, string) (*sql.Stmt, error)
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries { return &Queries{db: tx} }// ★★ 服務層開交易,把 tx 綁到一個新的 Queries 上
tx, err := db.BeginTx(ctx, nil)
defer tx.Rollback()
qtx := q.WithTx(tx)
if err := qtx.DecStock(ctx, …); err != nil { … }
if _, err := qtx.InsertOrder(ctx, …); err != nil { … }
return tx.Commit()★★ 這是整個 Go 資料層最重要的一個模式:
把「要不要在交易裡」變成一個參數,而不是兩套方法。
⚠️ 常見錯誤:閘道自己開交易 → 呼叫端無法把多個操作放進同一個交易。
→ ★ 閘道永遠不開交易;交易邊界屬於 服務層。
★★ 三個 Go 的實務細節
// ① ★★ NULL:用 sql.Null[T](Go 1.22+ 的泛型版本,取代 sql.NullString 那一堆)
var name sql.Null[string]
err := row.Scan(&name)
if name.Valid { use(name.V) }
// ★ 直接 Scan 到 *string 在 NULL 時會報錯:converting NULL to string is unsupported
// ② ★★ 「更新了幾列」是唯一能判斷樂觀鎖成敗的方式
res, err := q.UpdateOrder(ctx, params) // WHERE id = $1 AND version = $2
n, _ := res.RowsAffected()
if n == 0 { return ErrConcurrentModification } // ★ 沒有錯誤,但沒改到
// → 見 [[樂觀離線鎖]]
// ③ ★ rows 一定要 Close,而且要檢查 rows.Err()
rows, err := q.List(ctx)
defer rows.Close()
for rows.Next() { … }
if err := rows.Err(); err != nil { … } // ★★ 迴圈正常結束 ≠ 沒有錯誤★★ rows.Err() 是最常被漏掉的一行:迴圈因為網路中斷而提早結束時,
rows.Next() 只是回 false——你會拿到一份不完整的資料而完全不知道。
★ 兄弟辨析
| 差別 | |
|---|---|
| 列資料閘道 | ★ 一個物件代表一列;表格資料閘道是一個物件服務整張表 |
| 主動記錄 | 列資料閘道 + 業務邏輯 |
| 資料對應器 | ★ 在閘道之上把 row struct 轉成領域物件 |
| 查詢物件 | ★ 動態組查詢;表格資料閘道是預先寫好的固定查詢 |
| 閘道 | 一般化的「封裝外部資源」模式 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
只封裝資料存取、不放業務邏輯;邏輯要另找地方。
-
若偏好「一物件對一列」的存取風格,改用列資料閘道。
-
使用領域模型並要更徹底解耦時,改用資料對應器。
-
★★ 閘道永遠不自己開交易。 用
DBTX介面讓*sql.DB與*sql.Tx都能傳進來,交易邊界屬於 服務層。 -
★★
rows.Err()必查。 迴圈因網路中斷提早結束時Next()只回 false——你會拿到不完整的資料而不知道。 -
★★ NULL 要用
sql.Null[T](Go 1.22+ 泛型版)。直接 Scan 到*string在 NULL 時會報錯。 -
★★ 樂觀鎖只能靠
RowsAffected() == 0判斷——沒有錯誤,但沒改到。 -
★ 這個模式不幫你解耦領域模型。row struct 的形狀就是表的形狀;要領域模型就在它上面加一層。
-
★ 不要為每張表都產生完整 CRUD。用不到的查詢是死碼,而且會誘使別人用錯的方式存取。
-
SELECT *會在加欄位時破壞 scan。一律列出欄位(sqlc 會強制你這麼做)。 -
跨表的查詢不屬於任何單一「表格」閘道。放一個
ReportQueries或 查詢物件,不要硬塞。
🔗 相關工具
- 列資料閘道 Row Data Gateway(以「一列」為單位的閘道對照)
- 資料對應器 Data Mapper(更完整解耦領域物件與資料庫)
- 企業應用架構模式
- 列資料閘道 —— 一個物件代表一列
- 主動記錄 —— 列資料閘道 + 業務邏輯
- 資料對應器 —— 在閘道之上解耦領域模型
- 查詢物件 —— 動態查詢
- 閘道 —— 一般化的封裝
- 樂觀離線鎖 —— RowsAffected 的用途
- 工具-物件關聯映射模式 —— 三種資料源模式的選擇