🎯 什麼情境該想到我
當你「想把資料表查詢結果,當成記憶體中的表格資料直接使用」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:把查詢結果表示成記憶體中的表格資料結構,供 UI 與模組直接使用。
做法要點:
- 用列(row)與欄(column)組成的記憶體表格,鏡射資料庫的表格結構(如 .NET 的 DataSet)。
- UI 元件與商業模組可直接綁定、讀寫這份記錄集,開發直觀。
- 常見於「資料匯流排」風格:讓表格資料在各層之間流動、被繫結。
★★ 誠實的結論:這個模式在 Go 裡不存在,而那是好事
★ 它的原生環境是 .NET 的 ADO.NET DataSet(2002 年):
★★ 一個「通用的、離線的、可導覽的表格結構」
→ UI 控件可以直接綁定它、可以離線編輯、可以追蹤變更、可以整批寫回
→ ★ 它是 [[表格模組]] 與「快速開發」的基礎設施
★★ Go 只有 database/sql 的 *sql.Rows,而它是:
① ★ 游標(cursor),不是集合 —— 只能往前,讀完就沒了
② ★★ 必須 Close,而且不 Close 會洩漏連線
③ ★ 沒有型別資訊,要靠 Scan 到具體變數
④ ★★ 不能離線、不能編輯、不追蹤變更
★★ 所以 Go 的做法是:立刻 Scan 成具體的 struct slice,然後忘掉 rows。
★ 這就是sqlc產生的程式碼在做的事(見 表格資料閘道)。
★★ *sql.Rows 的四個必守規則
rows, err := q.Query(ctx, sqlText, args...)
if err != nil { return nil, fmt.Errorf("query: %w", err) }
defer rows.Close() // ★★ ① 必須 Close,否則連線不會還回池裡
var out []Order
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, &o.Status, &o.Total); err != nil { // ★ ② 順序必須和 SELECT 一致
return nil, fmt.Errorf("scan: %w", err)
}
out = append(out, o)
}
if err := rows.Err(); err != nil { // ★★ ③ 必查!
return nil, fmt.Errorf("rows: %w", err)
}
return out, nil // ★ ④ 回傳具體型別,不要回傳 rows★★ ③ 是 Go 資料存取最常被漏掉的一行:
★ 迴圈因為網路中斷、查詢被取消、伺服器關閉而提早結束時,
rows.Next() 只是回 false —— ★★ 和「正常讀完」完全一樣
→ ★ 你會拿到一份「不完整但看起來成功」的資料,而且沒有任何錯誤
→ ★★ 而這種 bug 在低流量時幾乎不會出現,上線後在尖峰時段偶發
★ ④ 也很重要:把 *sql.Rows 從函式回傳出去,
呼叫端必須記得 Close,而且那個連線在此期間一直被佔著——
★ 這是連線池耗盡的一條常見路徑。
★★ 什麼時候你真的需要「串流」而不是 slice
// ★ 一千萬列不能全部載進記憶體
// ★★ Go 1.23+ 的做法:回傳 iter.Seq2,讓呼叫端 range
func (q *Queries) StreamOrders(ctx context.Context) iter.Seq2[Order, error] {
return func(yield func(Order, error) bool) {
rows, err := q.db.QueryContext(ctx, sqlText)
if err != nil { yield(Order{}, err); return }
defer rows.Close() // ★ 由 iterator 負責關閉
for rows.Next() {
var o Order
if err := rows.Scan(&o.ID, …); err != nil { yield(Order{}, err); return }
if !yield(o, nil) { return } // ★★ 呼叫端 break 時,defer 會關閉
}
if err := rows.Err(); err != nil { yield(Order{}, err) }
}
}★★ iter.Seq2 讓「串流」與「資源管理」同時成立:
★ 呼叫端只要 for o, err := range …,而 Close 由迭代器負責——
★★ 這是 Go 1.23 之後處理大結果集最乾淨的形態(見 封裝集合)。
⚠️ 但串流有一個代價:連線在整個迭代期間被佔用。
★ 若迭代中還要做慢的處理(呼叫外部 API),★★ 連線會被佔住很久 →
★ 那時應該「分批查詢」(keyset pagination)而不是串流。
★★ 這個模式留給我們的唯一教訓
★★ 「通用的表格結構」聽起來很美,實際上它讓:
① ★ 沒有型別 ⇒ 打錯欄位名執行期才知道
② ★★ 沒有領域語義 ⇒ 業務邏輯只能寫成「對表格操作」(見 [[表格模組]])
③ ★ 離線編輯與變更追蹤 ⇒ 一套複雜的機制,而它解決的問題今天由
明確的交易與樂觀鎖處理得更好
→ ★★ Go 選了另一條路:★ 具體型別、明確的查詢、立刻 Scan 成 struct
★ 判準:你想要「一個能裝任何查詢結果的東西」嗎?
★★ 想要 → 先問為什麼。通常答案是「動態查詢」(→ 查詢物件)
或「報表」(→ 專門的 DTO),而兩者都不需要一個通用容器。
★ 兄弟辨析
| 差別 | |
|---|---|
| 表格資料閘道 | ★ Go 的答案:具體型別、立刻 Scan |
| 表格模組 | ★ 記錄集是它的前提,而那個前提今天不成立 |
| 資料傳輸物件 | ★ 具名的、型別化的「一包資料」 |
| 查詢物件 | 動態查詢的正確做法 |
| 封裝集合 | iter.Seq 的用法 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
以表格為中心,較偏離物件導向的領域模型;領域邏輯複雜時未必合適。
-
環境沒有內建記錄集型別時,自造成本高。
-
讓 UI 直接依賴資料庫結構,schema 變動的影響會擴散。
-
★★
rows.Err()必查。 迴圈因網路中斷提早結束時Next()只回 false,和正常讀完完全一樣——你會拿到不完整但看起來成功的資料。 -
★★ 這種 bug 在低流量幾乎不出現,上線後在尖峰時段偶發,極難重現。
-
★★ 不要把
*sql.Rows從函式回傳出去。 呼叫端要記得 Close,而連線在此期間一直被佔著——這是連線池耗盡的常見路徑。 -
★
defer rows.Close()不可省略,否則連線不會還回池裡。 -
★
Scan的參數順序必須和SELECT一致,加一個欄位就整排錯位。 -
★ 大結果集用
iter.Seq2串流(Go 1.23+),Close 由迭代器負責。 -
★★ 但串流會佔住連線整段時間。 迭代中要做慢處理 → 改用分批查詢(keyset pagination)。
-
★ 「通用的表格結構」在 Go 裡不該追求。 想要它通常代表你需要的是 查詢物件 或一個報表 DTO。
🔗 相關工具
- 閘道 Gateway(可包裝資料來源回傳記錄集)
- 值物件 Value Object(欄位值可用值物件表達)
- 企業應用架構模式
- 表格資料閘道 —— ★ Go 的答案
- 表格模組 —— 記錄集是它的前提
- 資料傳輸物件 —— 型別化的「一包資料」
- 查詢物件 —— 動態查詢
- 封裝集合 —— iter.Seq 的用法
- 延遲載入 —— 串流 vs 一次載入