🎯 什麼情境該想到我
當你「訊息格式會隨時間演進,接收端需要知道這一則是哪個格式/版本」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:在訊息中標明其資料格式,讓接收端能挑對解析方式、支援多版本並存與演進。
做法要點:在標頭放一個格式指示器,常見三種表示方式:
- 版本號:標出格式的版本。
- 格式代碼 / 鍵值:對應到已知的格式型別。
- 外部參照:指向格式定義(如 schema 的 URL)。
接收端據此指示器選用對應的解析邏輯。
★ 三種標示方式(各自的代價差很多)
| 做法 | 例子 | ★ 代價 |
|---|---|---|
| ① 版本號欄位 | {"schema_version": 3, …} | ★ 要自己維護「版本 → 解析方式」的對應 |
| ② ★ 格式指示器 + schema registry | Confluent 的 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 會讓程式碼永遠背著債。定一個下線日期。
🔗 相關工具
- 文件訊息 Document Message(資料本體常隨格式演進,需標版本)
- 命令訊息 Command Message(命令格式演進時同樣受用)
- 企業整合模式
- 信封包裝器 —— 格式指示器住在信封裡
- 資料型別通道 —— 用通道標示型別
- 訊息轉換器 —— 版本升級就是轉換
- 正規化器 —— 多格式歸一
- 無效訊息通道 —— 解不開時的去處
- 工具-資料編碼與演進 —— 相容性規則的完整討論