🎯 什麼情境該想到我

當你「處理繼承階層的持久化時,想用一套對應器結構避免各子類重複載入 / 儲存邏輯」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:以一組對應器(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 都要自己寫一遍。

🔗 相關工具