生而為數字,我很抱歉:C# enum 字串化的方向與選擇
生而為數字,我很抱歉:C# enum 字串化的方向與選擇
前言
C# 的列舉,骨子裡就是數字。Sent = 2 編譯後就是一個整數常數,執行期比對、預設 JSON 序列化、存進 DB,全都是 2。
數字對機器很友善,對人就不是這麼回事了:
前端拿到 2 要自己查對照表,DB 撈出一排 2 和 4 根本不知道是什麼。
所以實務上我們一直在做同一件事:給 enum 補一個「給人看、給外部系統用」的字串。
那直接用成員名稱(Sent)不行嗎?不太行。
它受 C# 識別字規則限制(PascalCase、不能有空格或連字號),
而且哪天重構改名,外部契約就跟著爆炸,不適合直接拿去對外。
於是「用 attribute 給 enum 成員掛一個字串」這個需求,就這樣貫穿了 .NET 二十多年的歷史,
而且每一代序列化技術都自己重新發明了一次。
掛上去的字串,最常見的用法是一個值同時當多份契約用:
JSON 序列化用它,DB 儲存也用它。同源、保證一致,平常這是優點。
但哪天 JSON 契約想改版(processing 改叫 in_progress),
DB 裡卻躺著幾十萬筆舊碼不想遷移,怎麼辦?
要不要自訂一個 attribute,分出一個 DB 專用值?

追查之後發現不用自訂,.NET 裡早就躺著一個現成的答案:[EnumMember]。
2006 年的 WCF 世代,就對「同一個需求」給過一次答案了。
它跟 [JsonStringEnumMemberName] 做的是同一件事,但彼此互不相認,
而這個特性剛好可以拿來讓它們各管一份契約。
這篇就把這兩個 attribute 的來龍去脈講清楚,
順便盤點 BCL 裡所有能給 enum 成員掛字串的 attribute。
懶人總結
[EnumMember] 和 [JsonStringEnumMemberName] 做的是同一件事:給 enum 成員掛一個字串值。
但它們是不同世代、不同序列化生態系的產物,彼此互不相認。
而「互不相認」這個看似缺點的特性,剛好可以拿來解決雙契約分岔的問題。
先並排看兩者的寫法:
using System.Runtime.Serialization;
public enum ContractStatus
{
// 注意:是具名參數 Value =
[EnumMember(Value = "sent")]
Sent = 2,
}using System.Text.Json.Serialization;
public enum ContractStatus
{
// 建構子參數,直接給值
[JsonStringEnumMemberName("sent")]
Sent = 2,
}| 面向 | EnumMemberAttribute | JsonStringEnumMemberNameAttribute |
|---|---|---|
| 命名空間 | System.Runtime.Serialization | System.Text.Json.Serialization |
| 出生年代 | .NET Framework 3.0(2006,WCF 時代) | .NET 9(2024) |
| 原生讀者 | DataContractSerializer(WCF XML 序列化,需搭配 [DataContract]) | JsonStringEnumConverter(System.Text.Json) |
| System.Text.Json 認不認 | ❌ 完全忽略 | ✅ 認(這正是它誕生的原因) |
| 第三方生態系 | ✅ 事實標準:Newtonsoft.Json 的 StringEnumConverter、Refit、Swashbuckle、部分 Dapper 擴充都讀它 | ❌ 太新,舊函式庫不認識 |
為什麼會有兩個?
需求明明一模一樣:enum 是數字,人要字串。
那 System.Text.Json 為什麼不直接認現成的 [EnumMember],要另外造一個?
先講背景:.NET 9 之前,System.Text.Json 完全沒有逐成員自訂 enum 字串值的辦法,
只能整組套 naming policy,或自己寫 converter。
社群從 2019 年就開了 issue(dotnet/runtime#31081)要求直接支援 [EnumMember]。
畢竟 Newtonsoft.Json 就是這樣做的,從 Newtonsoft 遷移過來的人都在找這個功能,
連 Newtonsoft 作者 James Newton-King 都在串裡補充說這是廣泛使用的功能。
但 .NET 團隊拒絕了。
System.Text.Json 維護者 Eirik Tsarpalis 在後續的 API 提案串(dotnet/runtime#74385)裡講得很直白:
System.Text.Json won't be supporting
EnumMemberAttributeout of the box because the STJ assembly doesn't have a dependency onSystem.Runtime.Serialization.(System.Text.Json 不會內建支援
EnumMemberAttribute,因為 STJ 組件根本不依賴System.Runtime.Serialization。)
更早之前他也說過,不太可能單獨為 [EnumMember] 破例,
理由跟其他 System.Runtime.Serialization 底下的 attribute 一樣:
「它們屬於不同的、更舊的序列化堆疊」("These pertain to different/older serialization stacks")。
於是 .NET 團隊乾脆造一個乾淨的新 attribute:2022 年提案、核准,最後在 .NET 9 落地成 JsonStringEnumMemberName。
結果就是現在兩個並存:老的統治舊生態系(Newtonsoft 系),新的只屬於 System.Text.Json。
我遇到的問題:一個 attribute、兩份契約
先把情境講具體一點。我專案裡的狀態欄位是這樣建模的:
- DomainEnum 成員標
[Description("中文")](人類顯示)+[JsonStringEnumMemberName("sent")](機器碼) - JSON 序列化走全域
JsonStringEnumConverter,自動輸出"sent" - DB 儲存走擴充方法
GetStringValue()(反射讀同一個 attribute),存入'sent' - 讀回用
ToEnum<TEnum>(),未知字串落到Unknown
好處是 JSON、DB、外部 API 碼三者同源,保證一致。
但這也表示 [JsonStringEnumMemberName] 一個 attribute 同時扛了兩份契約:
對前端的 JSON 契約、對 DB 既有資料的持久化契約。
改其中一份,另一份會被連坐。
先釐清一個誤會:前端「顯示」動不到它
- 前端要改顯示措辭(「已送出」改成「寄送完成」)→ 動的是
[Description],跟機器碼無關,完全不會碰到[JsonStringEnumMemberName]。 - 真的會動到
[JsonStringEnumMemberName]的只有 API 契約改版(例如processing想改叫in_progress)。這種事本來就會破壞前端既有整合,是大事,不會「不小心」發生。
所以平常日子裡,「一個 attribute 兩份契約」其實是正確的耦合:
兩份契約只會因為同一個理由(外部系統改碼)一起變,同源反而是保護。
真正的問題是分岔的那一天:JSON 契約要改,DB 既有資料又不想遷移。
分岔那天的解法:讓兩個 attribute 各管一份契約
真的遇到分岔,也不需要自訂 attribute,[EnumMember] 就是現成的 DB 值覆寫欄位。
把 GetStringValue / ToEnum 的查找優先序定為:
[EnumMember(Value = ...)] ← DB 持久化值(明確覆寫用)
→ [JsonStringEnumMemberName] ← 預設(與 JSON 同源)
→ 成員名稱 ← 最後退路分岔前後的成員長相對照:
/// <summary> 處理中 </summary>
[Description("處理中")]
[JsonStringEnumMemberName("processing")] // JSON 和 DB 都是 "processing"
Processing = 2,/// <summary> 處理中 </summary>
[Description("處理中")]
[EnumMember(Value = "processing")] // DB 繼續寫舊碼,既有資料不用遷移
[JsonStringEnumMemberName("in_progress")] // JSON 契約改版
Processing = 2,對應的擴充方法,只要在讀值時多查一層:
/// <summary> 列舉 → 字串(寫 DB);優先讀 [EnumMember],其次 [JsonStringEnumMemberName],未標註則回傳成員名稱 </summary>
public static string GetStringValue(this Enum value)
{
FieldInfo? field = value.GetType().GetField(value.ToString());
// 第一優先:DB 覆寫值(只有分岔的成員才會標)
var enumMember = field?.GetCustomAttribute<EnumMemberAttribute>();
if (enumMember?.Value is not null) return enumMember.Value;
// 預設:與 JSON 同源
var jsonName = field?.GetCustomAttribute<JsonStringEnumMemberNameAttribute>();
return jsonName?.Name ?? value.ToString();
}(ToEnum<TEnum>() 比對時則是兩個 attribute 的值都算 match,舊資料、新資料都讀得回來。)
這個設計有幾個好處:
- 向後相容零成本:沒標
[EnumMember]的成員行為完全不變,既有 enum 一個都不用改,只有真正分岔的成員多一行。 - 舊資料讀得回來:
ToEnum兩個碼都認,DB 裡的processing和新寫入的都能對回Processing。 - 測試自然分成兩組:
GetStringValue()釘 DB 契約、JsonSerializer.Serialize()釘 JSON 契約,分岔後各有各的守衛。 - 兩個 attribute 永遠不會打架:System.Text.Json 根本不認識
[EnumMember],JSON 序列化不受影響;GetStringValue是自己寫的反射方法,優先序自己說了算。
為什麼選 [EnumMember] 當 DB 覆寫,而不是自訂 attribute?
| 面向 | 自訂 attribute | 借用 [EnumMember] |
|---|---|---|
| 額外程式碼 | ❌ 要自己定義、維護一個類別 | ✅ BCL 內建,零套件、零自訂 |
| 語意 | 自己取名自己解釋 | ✅ 本義就是「這個成員序列化出去的值」,拿來標持久化值名正言順 |
| 與 System.Text.Json 的關係 | 一樣互不干擾 | ✅ 一樣互不干擾(「互不相認」從缺點變成特性) |
| 團隊認知成本 | ❌ 新人要學一個專案私有概念 | ✅ 老 .NET 人一看就懂 |
一個小地雷:管線裡混有 Newtonsoft 系元件時
如果專案裡混有走 Newtonsoft.Json 的第三方元件(某些舊版 OpenAPI 工具、SDK),它們會認 [EnumMember],序列化出來的會是 DB 值而不是 JSON 契約值。全站統一 System.Text.Json 就沒這個問題,但引入第三方序列化元件時,記得檢查它用哪套序列化器。
盤點:還有哪些 attribute 能給 enum 成員掛字串?
把 BCL 裡「可以掛在 enum 成員(field)上、承載一個字串」的 attribute 全部攤開,
會發現每個都是某一代序列化/顯示技術的產物,各自有原生讀者:
內建可用的五個
| Attribute | 命名空間 | 世代 | 原生讀者 | 適合的角色 |
|---|---|---|---|---|
[Description] | System.ComponentModel | .NET 1.x | WinForms 設計工具;慣例上大家自己反射讀 | 人類顯示文字(如 Swagger/Scalar 的中文說明) |
[Display] | System.ComponentModel.DataAnnotations | .NET 4 | ASP.NET MVC / Razor | 需要本地化的顯示文字:Name/ShortName/Description/Order 多欄位,且支援 ResourceType 接 resx 資源檔;哪天要多語系,就是 [Description] 的升級版 |
[XmlEnum] | System.Xml.Serialization | .NET 1.x | XmlSerializer | XML 契約值:對接 SOAP / XML API 時的 [JsonStringEnumMemberName] 對應物 |
[EnumMember] | System.Runtime.Serialization | .NET 3.0(WCF) | DataContractSerializer;Newtonsoft 生態事實標準 | DB 持久化覆寫值(本文的分岔預案) |
[JsonStringEnumMemberName] | System.Text.Json.Serialization | .NET 9 | JsonStringEnumConverter | JSON 契約值 |
照年代排起來蠻有趣的:
XmlSerializer(1.x)→ WinForms 元件模型(1.x)→ WCF(3.0)→ DataAnnotations(4.0)→ System.Text.Json(Core 世代)。
需求從頭到尾只有一個:enum 是數字,人要字串。
但每一代都重新發明一次答案,而且互不相認。
也正因為互不相認,才有辦法讓它們各管一份契約、和平共存。
兩個常見陷阱(掛不上去的)
| Attribute | 為什麼不行 |
|---|---|
[DisplayName](System.ComponentModel) | AttributeUsage 只含 Class/Method/Property/Event,不含 Field,編譯直接報錯。名字跟 [Display] 只差幾個字母,最容易搞混 |
[JsonPropertyName] | 只能掛 property,enum 成員是 field。.NET 9 之前很多人想拿它改 enum 字串值,此路不通(所以才有 .NET 9 的新 attribute) |
供應商專屬的例子
Npgsql 有自己的 [PgName](NpgsqlTypes),
把 .NET enum 對應到 PostgreSQL 原生 enum type 時,可以指定每個成員的 DB 值,
等於是 Npgsql 版的 EnumMember。
但它是 provider 專屬的,如果架構要支援多種資料庫就不能依賴它。
另外一個冷知識:Newtonsoft.Json 自己沒有 enum 成員的 attribute。
它的 StringEnumConverter 直接讀 [EnumMember],
這也是 [EnumMember] 會變成舊生態系事實標準的原因。
幾個值得記住的觀念
- 沒有誰更「官方」:對
GetStringValue/ToEnum這類自製反射方法而言,兩個 attribute 都只是「掛在 enum 欄位上的字串」。EF Core 兩個都不認識,是你的程式碼賦予它們意義。 - 不用預先加第二個 attribute:JSON 與 DB 同源是正確的耦合,兩者只會因為同一個理由一起變。要做的是把分岔的縫留在唯一的轉換點(
GetStringValue/ToEnum),分岔那天只要改兩個方法加一行 attribute,不用全域搜尋替換。 - 單一事實來源有個隱含前提:兩份契約要有同一個變更理由。前提破掉的那天,就是拆成兩個事實來源的那天。拆的時候不用急著發明新機制,先翻翻 BCL,說不定答案已經在那邊躺了二十年。
小結
兩個主角的關係,一張表收尾:
[EnumMember] | [JsonStringEnumMemberName] | |
|---|---|---|
| 世代 | WCF(2006) | System.Text.Json(.NET 9,2024) |
| System.Text.Json | ❌ 完全忽略 | ✅ 原生支援 |
| Newtonsoft / 舊生態系 | ✅ 事實標準 | ❌ 不認識 |
| 建議角色 | DB 持久化覆寫值(分岔時啟用) | JSON 契約值(平常唯一事實來源) |
加上顯示用的 attribute,一個 enum 成員的完整角色分配:
| 角色 | Attribute | 時機 |
|---|---|---|
| 人類顯示(單語) | [Description] | 平常 |
| 人類顯示(多語系) | [Display] + ResourceType | 需要 i18n 時升級 |
| JSON 契約 | [JsonStringEnumMemberName] | 平常 |
| DB 持久化覆寫 | [EnumMember] | JSON / DB 契約分岔時啟用 |
| XML 契約 | [XmlEnum] | 對接 SOAP / XML 時啟用 |
如果哪天角色多到連這張表都不夠用(例如同一個 enum 要對五套外部系統各出一個碼),
那就不是 attribute 能解的了,該考慮 class-based enum(如 Ardalis.SmartEnum 或自建常數類別),
把每個成員變成能攜帶任意屬性的物件。
attribute 模式的甜蜜點是「角色少而穩定」。
最後總結一句:enum 骨子裡是數字,「人要字串」這個需求每一代序列化技術都重新回答過一次。[EnumMember] 和 [JsonStringEnumMemberName] 就是相隔 18 年的兩個答案,
而互不相認不是 bug,剛好是讓它們各管一份契約的空間。
環境:.NET 10 / ASP.NET Core / System.Text.Json。JsonStringEnumMemberName 需 .NET 9 以上;EnumMember 自 .NET Framework 3.0 起內建。
參考資料
- 如何使用 System.Text.Json 自訂屬性名稱與值 — 自訂列舉成員名稱 | Microsoft Learn
- Support for EnumMemberAttribute in JsonConverterEnum · dotnet/runtime #31081 — 2019 年社群要求支援
[EnumMember]的原始 issue - Support customizing enum member names in System.Text.Json · dotnet/runtime #74385 —
JsonStringEnumMemberName的 API 提案與團隊拒絕支援[EnumMember]的說明 - EnumMemberAttribute 類別 | Microsoft Learn
- JsonStringEnumMemberNameAttribute 類別 | Microsoft Learn
- XmlEnumAttribute 類別 | Microsoft Learn
- DisplayAttribute 類別 | Microsoft Learn