🎯 什麼情境該想到我

當你「想用一支轉換程式,把領域資料逐項轉成 HTML 輸出」的時候。

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

意圖:以「轉換」為核心——寫程式逐項讀取領域資料,逐一產生對應的 HTML 元素。

做法要點:

  • 為每種資料元素定義如何轉成輸出片段,逐項轉換組成整頁。
  • 思維是「輸入 → 轉換 → 輸出」,而非在版型上挖洞填值。
  • XSLT 是典型代表(把 XML 轉成 HTML)。
  • 轉換邏輯是純程式,較易做單元測試。

★★ 今天最常見的轉換視圖就是 json.Marshal

★ PoEAA 的原始例子是 XSLT:遍歷輸入的結構,逐個元素轉成輸出
★★ 今天的等價物:
  ① json.Marshal(view)         ← ★ 最常見,遍歷 struct 產生 JSON
  ② protobuf 序列化
  ③ 前端的 React 元件樹        ← ★ 遍歷資料產生 DOM,這也是轉換視圖

★★ 和 樣板視圖 的本質差別:
樣板視圖是「一份輸出骨架 + 在裡面挖洞」;
轉換視圖是「遍歷輸入資料,每種型別知道自己怎麼輸出」。
★ 所以轉換視圖天然處理「結構不固定」的資料(遞迴、多型、任意深度)。

★★ Go 的 JSON 編碼:六個會出事的細節

// ① ★★ nil slice 與空 slice 序列化不同
var a []Item            // → null
b := []Item{}           // → []
// ★★ 前端 a.map(...) 會炸。★ 一律在組 view 時初始化為空 slice
 
// ② ★★ int64 傳到 JavaScript 會失真
type View struct{ ID int64 `json:"id"` }     // ★ 9007199254740993 → 前端拿到錯的值
type View struct{ ID string `json:"id"` }    // ✅ ID 一律用字串
 
// ③ ★★ omitempty 分不出「零值」與「沒有」
Discount int `json:"discount,omitempty"`     // ★ 0 就不輸出 → 前端不知道是 0 還是沒給
Discount *int `json:"discount"`               // ✅ 需要區分時用指標
 
// ④ ★★ time.Time 的零值會輸出 "0001-01-01T00:00:00Z"
PaidAt time.Time  `json:"paid_at"`           // ★ 看起來像一個真的時間
PaidAt *time.Time `json:"paid_at"`           // ✅ 未付款就是 null
 
// ⑤ ★ 未匯出欄位不會被序列化(★ 這是好事,但會靜默漏掉)
type View struct{ total Money }              // ★★ 輸出 {} —— 沒有任何錯誤
 
// ⑥ ★★ encoding/json 對 HTML 字元會轉義(&, <, > → & 等)
// ★ 這是為了安全(可安全嵌入 <script>),但會讓輸出看起來很怪
// → ★ 要關掉:enc := json.NewEncoder(w); enc.SetEscapeHTML(false)
//   ⚠️ 但關掉之後就不能直接嵌進 HTML 了

★★ ①④ 是前端最常抱怨的兩件事,而它們在 Go 端完全不會報錯。

★★ 串流輸出:轉換視圖唯一真正的技術優勢

// ★★ 大量資料不要先組成一個大 slice 再 Marshal
// ❌ 十萬筆訂單 → 記憶體裡一份 slice + 一份 JSON bytes
all, _ := q.ListAllOrders(ctx)
json.NewEncoder(w).Encode(all)
 
// ✅ ★ 邊查邊寫,記憶體恆定
w.Header().Set("Content-Type", "application/json")
w.Write([]byte("["))
first := true
for rows.Next() {
    var o Order
    if err := rows.Scan(&o…); err != nil { … }
    if !first { w.Write([]byte(",")) }
    first = false
    if err := json.NewEncoder(w).Encode(toView(o)); err != nil { … }
}
w.Write([]byte("]"))
if err := rows.Err(); err != nil { … }     // ★★ 必查(見 [[表格資料閘道]])

★★ 但串流輸出有一個無法迴避的問題:你已經寫了 200 和一半的 body,
中途失敗時沒辦法改成 500。

★ 三個對策:

① ★★ 改用 NDJSON(每行一個物件)—— ★ 客戶端可以逐行處理,
   而且「最後一行不完整」是一個可偵測的錯誤訊號
② ★ 在輸出結尾放一個明確的「完成標記」
③ ★★ 或乾脆分頁,不要串流(★ 多數情況的正確答案)

★ 什麼時候轉換視圖勝過樣板視圖

  • ★★ 輸出結構由資料決定(遞迴的樹、多型的節點、任意深度的巢狀)。
    ★ 樣板要處理遞迴很痛;轉換視圖天然遞迴。
  • ★ 同一份資料要輸出多種格式(JSON/CSV/XML)→ ★ 每種格式一個轉換器。
  • ★★ 輸出給程式讀而非人讀 → JSON/protobuf。

★ 什麼時候樣板視圖勝過

  • ★ 輸出骨架固定、只有少數幾個洞 → ★ 樣板一眼看得出最終長相。
  • ★ 非工程師要改版面 → 樣板檔比 Go 程式碼好改。

★ 兄弟辨析

差別
樣板視圖★ 輸出骨架 + 挖洞;適合固定版面
兩步驟視圖★ 先邏輯結構、再樣式;★ 可以兩階段都用轉換視圖
資料傳輸物件★ 被轉換的那個 view struct
訊息轉換器訊息世界的對應物

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 版型不像樣板視圖那樣一眼可見,設計人員較難直接參與。

  • 複雜版面用轉換描述可能冗長難讀(尤其 XSLT)。

  • 需要接近成品的可視版型時,樣板視圖較直覺。

  • ★★ nil slice 序列化成 null、空 slice 成 []。 前端 .map() 會炸。組 view 時一律初始化為空 slice。

  • ★★ int64 傳到 JavaScript 會失真(53 bit 安全整數)。ID 一律用字串。

  • ★★ time.Time 的零值輸出 "0001-01-01T00:00:00Z",看起來像真的時間。可為空的時間用 *time.Time。

  • ★★ omitempty 分不出「零值」與「沒有」。 需要區分時用指標。

  • ★★ 串流輸出無法在中途改成 500(已寫了 200 和一半 body)。用 NDJSON、放完成標記,或乾脆分頁。

  • ★ 未匯出欄位不會被序列化,而且沒有任何錯誤。 輸出 {} 是常見的困惑來源。

  • ★ encoding/json 預設轉義 HTML 字元(& → &)。SetEscapeHTML(false) 可關,但關了就不能嵌進 HTML。

  • ★ 大量資料不要先組成大 slice 再 Marshal,那是記憶體的兩倍。

  • 串流時 rows.Err() 必查,否則中斷會產生不完整但看起來成功的輸出。

🔗 相關工具