🎯 什麼情境該想到我

當你「訊息格式會隨時間演進,接收端需要知道這一則是哪個格式/版本」的時候。

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

意圖:在訊息中標明其資料格式,讓接收端能挑對解析方式、支援多版本並存與演進。

做法要點:在標頭放一個格式指示器,常見三種表示方式:

  • 版本號:標出格式的版本。
  • 格式代碼 / 鍵值:對應到已知的格式型別。
  • 外部參照:指向格式定義(如 schema 的 URL)。
    接收端據此指示器選用對應的解析邏輯。

★ 三種標示方式(各自的代價差很多)

做法例子★ 代價
① 版本號欄位{"schema_version": 3, …}★ 要自己維護「版本 → 解析方式」的對應
② ★ 格式指示器 + schema registryConfluent 的 wire format:magic byte + 4 bytes schema ID★ 多一個要維運的服務;但相容性檢查是自動的
③ ★ 通道即版本orders.order.placed.v1 / .v2★ 最簡單也最可靠;代價是 v1/v2 期間要雙寫

★★ 推薦組合:③ 為主,① 為輔。
通道名帶大版本(不相容變更 → 新通道);訊息裡的 schema_version 標小版本(相容變更)。
這樣消費者訂閱 .v1 就知道自己一定解得開,不必先讀 body 再判斷。

★★ Confluent wire format(Kafka + schema registry 的事實標準)

byte 0      : ★ magic byte = 0x00
bytes 1–4   : ★ schema ID(big-endian int32)
bytes 5–    : Avro/protobuf/JSON 的實際 payload

★ 這就是「格式指示器」在 Kafka 生態的具體長相。
好處是消費者拿到訊息先讀 5 個位元組就知道 schema,而且 registry 可以在「註冊新 schema 時」
就拒絕不相容的變更
——★ 把相容性從「執行期發現」變成「發布期擋住」。

⚠️ 代價:

  • 那 5 個位元組不是標準 Avro/protobuf,用一般的解碼器會直接失敗(很常見的踩雷點)。
  • registry 掛了 → 新的 schema 解不開(要有本地快取)。

★ Go 的實作:先讀指示器,再決定怎麼解

// ★ 關鍵設計:解析分兩步。先讀信封(永不變),再依版本解內容
type Envelope struct {
    Type          string          `json:"type"`
    SchemaVersion int             `json:"schema_version"`
    Data          json.RawMessage `json:"data"`       // ★ 延遲解析
}
 
func decode(b []byte) (any, error) {
    var e Envelope
    if err := json.Unmarshal(b, &e); err != nil {
        return nil, fmt.Errorf("%w: envelope: %v", ErrInvalidMessage, err)
    }
    switch {
    case e.SchemaVersion > maxSupported:
        // ★★ 未來版本:明確拒絕,不要試著解
        return nil, fmt.Errorf("%w: schema v%d > 支援上限 v%d",
            ErrInvalidMessage, e.SchemaVersion, maxSupported)
    case e.SchemaVersion == 2:
        var v OrderPlacedV2; return v, json.Unmarshal(e.Data, &v)
    case e.SchemaVersion == 1:
        var v OrderPlacedV1                              // ★ 舊版仍要能解
        if err := json.Unmarshal(e.Data, &v); err != nil { return nil, err }
        return upgradeV1toV2(v), nil                      // ★ 在邊界升級成內部模型
    }
    return nil, fmt.Errorf("%w: 未知版本 %d", ErrInvalidMessage, e.SchemaVersion)
}

★★ upgradeV1toV2 這一步是關鍵:在邊界把所有舊版升級成單一內部模型,
業務邏輯只認識最新版。否則 if version == 1 { … } else { … } 會滲透到整個程式碼庫。

★★ 「未來版本」的處理是最容易做錯的

★ 收到比自己支援的更新的版本,三種做法:
① 拒絕並送 [[無效訊息通道]]   → ★ 安全,但如果是滾動部署期間,會誤判大量訊息
② 忽略不認識的欄位、盡力解析   → ★ 對「只加欄位」的相容變更是正確的
③ 靜默丟棄                    → ★★ 絕對不要

✅ 正確做法取決於相容性契約:
  如果規定「小版本只加可選欄位」→ ② 是對的(JSON 與 protobuf 天然支援)
  如果是大版本(通道都不同)→ 根本不會收到,問題不存在

★ 滾動部署期間新舊消費者並存,是這個問題唯一真正會發生的場景——
所以「小版本只加可選欄位、且舊消費者忽略不認識的欄位」這條契約特別重要。

★ 什麼時候不需要格式指示器

  • ★ 用了 資料型別通道 + 通道名帶版本 → 訂閱 .v1 就保證是 v1,指示器是多餘的。
  • protobuf → ★ 它本身就設計成「未知欄位可忽略、欄號不重用」,多數變更不需要版本號。
  • 內部通道、單一生產者與消費者、一起部署。

★ 兄弟辨析

差別
信封包裝器★ 整個「外層包裝」的概念;格式指示器是包裝裡的一個欄位
資料型別通道★ 用「通道」而不是「欄位」來標示型別
訊息轉換器版本升級(upgradeV1toV2)就是它
正規化器多種格式 → 一種,通常靠格式指示器分辨來源

🧪 我實際套用的紀錄

  • (待填)

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

  • 需事先約定指示器的取值與其對應的格式。

  • 接收端要維護多版本的解析邏輯。

  • 若訊息未標版本,日後要向後相容會很困難。

  • ★★ 推薦「通道名帶大版本 + 訊息帶小版本」。訂閱 .v1 就保證解得開,不必先讀 body 再判斷。

  • ★★ 在邊界把舊版升級成單一內部模型。否則 if version == 1 會滲透到整個程式碼庫。

  • ★ Confluent wire format 前面有 5 個位元組(magic byte + schema ID),用一般 Avro/protobuf 解碼器會直接失敗。

  • ★ schema registry 的價值是「發布期擋住不相容變更」,不只是標示版本。但它是要維運的服務,要有本地快取。

  • ★★ 收到「未來版本」絕對不要靜默丟棄。 依相容性契約決定拒絕或盡力解析。

  • ★ 滾動部署期間新舊消費者並存是版本問題唯一真正會發生的場景——所以「只加可選欄位」這條契約很重要。

  • protobuf 多數變更不需要版本號(未知欄位可忽略),但欄號不可重用、型別不可改。

  • 版本號要有「支援下限」:永遠支援 v1 會讓程式碼永遠背著債。定一個下線日期。

🔗 相關工具