🎯 什麼情境該想到我
當你「處理繼承階層的持久化時,想用一套對應器結構避免各子類重複載入 / 儲存邏輯」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:以一組對應器(Mapper)結構來組織繼承階層的載入與儲存,讓子類別的 Mapper 能沿用父類別 Mapper 的共通邏輯。
做法要點:
- 對應器本身沿繼承階層組織:抽象 Mapper 放共通對應邏輯,具體 Mapper 補各自差異。
- 搭配單表 / 類別表 / 具象表繼承任一種資料表策略運作。
- 由一個上層負責挑選正確的具體 Mapper(依型別)來載入 / 儲存物件。
- 目的在減少繼承對應中的重複程式,讓結構清楚可擴充。
★★ 先講 Go:這個模式的前提在 Go 不成立
// ★ Go 沒有繼承,而嵌入沒有虛擬分派(見 [[工具-Go嵌入機制]])
type Base struct{}
func (b Base) Name() string { return "base" }
func (b Base) Greet() string { return "hi, " + b.Name() } // ★ b 永遠是 Base
type Child struct{ Base }
func (c Child) Name() string { return "child" }
Child{}.Greet() // ★★ "hi, base"★★ 所以「把繼承階層存進資料庫」這個問題,在 Go 裡換成
「把一組介面實作/判別聯集存進資料庫」。
★ 三種對應方式仍然適用,只是「子類」變成「實作型別」(見 以子類取代型別碼)。
★★ 它要解決的問題:三種繼承對應的「共同部分」在哪裡
★ 無論用 [[單表繼承]]/[[類別表繼承]]/[[具象表繼承]],你都需要:
① 依 kind 決定「建立哪個具體型別」 ← ★ 共同
② 把共同欄位(id、amount、created_at)填好 ← ★ 共同
③ 把專屬欄位填好 ← ★ 各自不同
④ 寫入時同樣分成共同與專屬 ← ★ 同上
→ ★★ 繼承對應器就是「把 ①② 抽出來,只讓子對應器負責 ③④」
★★ 這是 範本方法模式 在持久化層的應用——
★ 而範本方法在 Go 裡不成立(嵌入沒有虛擬分派),所以 Go 要換一種寫法。
★★ Go 的做法:函式表,不是繼承階層
// ★ 每個具體型別一組「讀/寫」函式
type paymentMapper struct {
fromRow func(db.Payment, db.Querier, context.Context) (Payment, error)
insert func(context.Context, db.Querier, Payment) error
}
// ★★ 分派表取代繼承階層(見 [[以多型取代條件式]] 的 Go 做法)
var mappers = map[Kind]paymentMapper{
KindCard: {fromRow: cardFromRow, insert: insertCard},
KindTransfer: {fromRow: transferFromRow, insert: insertTransfer},
}
// ★ 共同部分只寫一次
func (r *Repo) Get(ctx context.Context, q db.Querier, id ID) (Payment, error) {
head, err := q.GetPayment(ctx, string(id)) // ★ ① 共同欄位
if err != nil { return nil, fmt.Errorf("get payment: %w", err) }
m, ok := mappers[Kind(head.Kind)]
if !ok {
return nil, fmt.Errorf("unknown payment kind %q", head.Kind) // ★★ 必須處理
}
return m.fromRow(head, q, ctx) // ★ ② 專屬部分分派出去
}★★ 這個寫法的三個好處:
① ★ 共同邏輯(載入父列、錯誤包裝、樂觀鎖檢查)只寫一次
② ★★ 加一個型別 = 加一筆 map 項目 + 兩個函式,★ 不必碰既有的碼([[工具-開閉原則]])
③ ★ 每個 mapper 是純函式組合,可以獨立測試
★★ Go 沒有窮盡檢查,所以要配一個測試
// ★★ 這個測試是整個模式的安全網
func TestAllKindsHaveMapper(t *testing.T) {
for _, k := range AllKinds { // ★ AllKinds 是一個明確的清單
if _, ok := mappers[k]; !ok {
t.Errorf("kind %v 沒有 mapper", k)
}
}
}★★ 沒有它,「新增一個 Kind 但忘記註冊 mapper」會在生產環境的某一筆資料上才爆——
而且是「讀取失敗」而非「寫入失敗」,所以你的寫入測試不會發現。
→ ★ 這是把「編譯期窮盡檢查」換成「CI 窮盡檢查」(見 以多型取代條件式)。
★★ 共同欄位放哪裡:Go 的兩個選項
// ★ ① 嵌入一個共用 struct(★ 只共用資料,不共用行為)
type paymentBase struct {
id ID
amount Money
version int64
}
func (b paymentBase) ID() ID { return b.id } // ★ 純存取,沒有虛擬分派問題
func (b paymentBase) Amount() Money { return b.amount }
type CardPayment struct {
paymentBase // ★ 嵌入
last4 string
}
// ✅ 安全:paymentBase 的方法不呼叫任何「子型別覆寫」的方法
// ★★ ② 介面 + 各自實作(若共同欄位很少)
type Payment interface {
ID() ID
Amount() Money
Kind() Kind
}★★ ① 的安全前提:paymentBase 的方法不可以呼叫任何預期被覆寫的方法。
func (b paymentBase) Describe() string { return b.Kind() + ": " + … } // ★★ 錯!
// ★ paymentBase 沒有 Kind(),而即使有,也永遠是 paymentBase 自己的→ ★ 需要「共同骨架 + 各自細節」時,用函式欄位注入(見 層超型)。
★ 什麼時候不需要它
- ★ 只有兩個型別,且對應邏輯很短 → 一個
switch就好,不必抽出對應器。 - ★ 型別間幾乎沒有共同欄位 → 沒有可抽的東西;★ 那應該是兩個獨立的 repo。
- ★ 用了 具象表繼承 且各型別完全獨立 → 各自一個 mapper,不需要共同層。
★ 兄弟辨析
| 差別 | |
|---|---|
| 單表繼承/類別表繼承/具象表繼承 | ★ 三種 schema;繼承對應器是對應邏輯的組織方式 |
| 層超型 | ★ 更一般化:任何層的共同基礎型別 |
| 中繼資料對應 | ★ 用元資料驅動對應,而非手寫分派 |
| 範本方法模式 | ★ 原始的靈感來源,★ 而它在 Go 不成立 |
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
是輔助組織對應器的結構,需搭配某種繼承表策略,不能單獨決定資料表配置。
-
階層簡單時,直接寫對應可能更省事。
-
多數成熟 ORM 已內建處理,通常不必手工打造。
-
★★ 範本方法在 Go 不成立(嵌入沒有虛擬分派)。用「函式表 + 分派 map」取代繼承階層。
-
★★ 必須有「所有 Kind 都有 mapper」的測試。 忘記註冊會在讀取某一筆資料時才爆,而寫入測試不會發現。
-
★★ 嵌入的共用 struct 的方法不可以呼叫預期被覆寫的方法。需要「骨架 + 細節」時用函式欄位注入。
-
★ 未知 kind 一定要回錯,不要靜默回零值。 滾動部署期間新版可能寫入舊版不認識的 kind。
-
★ 只有兩個型別且對應邏輯很短 → 一個
switch就好,不必抽出對應器。 -
★ 型別間幾乎沒有共同欄位 → 那應該是兩個獨立的 repo,不是一個繼承對應器。
-
共同邏輯要包含樂觀鎖檢查與錯誤包裝,否則每個子 mapper 都要自己寫一遍。
🔗 相關工具
- 單表繼承 Single Table Inheritance(可搭配的資料表策略)
- 類別表繼承 Class Table Inheritance(可搭配的資料表策略)
- 具象表繼承 Concrete Table Inheritance(可搭配的資料表策略)
- 企業應用架構模式
- 單表繼承、類別表繼承、具象表繼承 —— 三種 schema
- 層超型 —— 更一般化的共同基礎型別
- 中繼資料對應 —— 元資料驅動的替代
- 範本方法模式 —— ★ 原始靈感,但在 Go 不成立
- 以多型取代條件式 —— Go 的分派表做法
- 以子類取代型別碼 —— 介面取代繼承
- 工具-開閉原則 —— 加型別不改既有碼