枚举转中文描述是业务系统的刚需:状态字段要显示”成功/失败”而不是
0/1,下拉框要列出所有可选项,导入结果文件名要带上状态说明。最常见的写法是每次调用都
ToString() 再反射读
DescriptionAttribute——反射虽好用,但
GetCustomAttribute
内部要做元数据扫描,在高频接口里反复执行就是白白浪费 CPU。
本文基于 SwitchData 项目中的 EnumExtensions
实现,拆解一套”反射只跑一次”的枚举元数据缓存方案,涉及三个关键技术点:泛型静态类的缓存魔法、.NET
8 的
FrozenDictionary、未定义枚举值的防御式处理。
一、需求场景
先看真实业务里枚举的三个典型用法:
// 场景1:状态转中文描述,用于错误提示、文件命名
string message = state.GetDescription();
string fileName = $"入库结果-{importState.GetDescription()}-{DateTime.Now.GetTimeStamp()}.xlsx";
// 场景2:枚举整体转下拉框选项(Text=中文,Value=名称)
var options = EnumExtensions.ToList<WorkImportState>()
.Select(item => new SelectListItem { Text = item.Description, Value = item.Name });
// 场景3:按中文描述反查枚举值(解析外部数据时常用)
WorkImportState? state = EnumExtensions.Parse<WorkImportState>("成功");
对应的枚举定义很常规,值上标 [Description] 特性:
public enum WorkImportState
{
[Description("失败")] Failure = 0,
[Description("成功")] Success = 1,
[Description("警告")] Warning = 2,
[Description("未知")] Unknown = 3
}
这三个场景共同的特点:读多写少,数据固定。枚举成员编译期就定死了,运行期永远不会变——这正是缓存的最佳适用场景。
二、整体设计:三层结构
flowchart TD
A["调用 GetDescription<T>()"] --> B{"EnumCache<T>.Cache"}
B -->|"首次访问"| C["static 构造器执行<br/>Enum.GetValues + 反射读 Description"]
C --> D["构建 FrozenDictionary 冻结缓存"]
D --> E["O(1) TryGetValue 查询"]
B -->|"已初始化"| E
E --> F["命中: 返回 Description<br/>未命中: 返回 value.ToString()"]
设计分三层:
- API 层:
GetDescription/ToList/Parse三个扩展方法,调用方无感知; - 缓存层:
EnumCache<T>泛型静态类,每个枚举类型对应一份独立缓存; - 数据层:
FrozenDictionary存元数据,IList存有序列表。
三、核心实现
3.1 泛型静态类:每个 T 一份独立缓存
先看缓存容器本身:
private static class EnumCache<T> where T : struct, Enum
{
public static readonly FrozenDictionary<T, (string Name, long Value, string Description)> Cache;
public static readonly IList<(string Name, long Value, string Description)> List;
static EnumCache()
{
var dict = new Dictionary<T, (string Name, long Value, string Description)>();
var type = typeof(T);
var values = Enum.GetValues<T>();
foreach (var value in values)
{
if (dict.ContainsKey(value)) continue; // 跳过同值成员
string name = value.ToString();
var field = type.GetField(name);
var attr = field?.GetCustomAttribute<DescriptionAttribute>();
string description = attr?.Description ?? name;
long longValue = Convert.ToInt64(value);
dict[value] = (name, longValue, description);
}
Cache = dict.ToFrozenDictionary();
List = Cache.Select(kvp => (kvp.Value.Name, kvp.Value.Value, kvp.Value.Description))
.ToList().AsReadOnly();
}
}
这里的灵魂是 EnumCache<T>
这个私有泛型静态类。CLR
对泛型类型的处理规则是:每个封闭泛型类型(closed generic
type)都有自己独立的一组静态字段。EnumCache<WorkImportState>
和 EnumCache<ExecutionStatus>
在运行时是两个完全不同的类型,各自的 Cache 互不干扰。
这带来三个好处:
- Key 编译期确定:不需要
ConcurrentDictionary<Type, object>这种以 Type 为键的字典,也就没有装箱和强转; - 线程安全免费拿:静态构造器由 CLR
保证在类型首次使用前执行且只执行一次,天然线程安全,不需要
Lazy<T>或双检锁; - 类型安全:
Cache的键就是T本身,取值时零转换。
where T : struct, Enum
约束则把范围限定在枚举上——既防止了随便什么类型都来蹭缓存,也让
Enum.GetValues<T>() 这类泛型 API 得以使用。
3.2 FrozenDictionary:为”只读”而生的字典
.NET 8 引入的 FrozenDictionary
是这套方案的性能关键。它与普通 Dictionary
的区别在于构建与查询的取舍:
| 类型 | 构建成本 | 查询成本 | 可变性 |
|---|---|---|---|
Dictionary<K,V> |
低 | 基准 | 可变 |
ImmutableDictionary<K,V> |
中 | 较慢(树结构) | 不可变 |
FrozenDictionary<K,V> |
较高(深度优化布局) | 最快 | 冻结 |
ToFrozenDictionary()
在冻结时会根据数据特征(键的哈希分布、数量、是否连续整数等)选择最优的内部实现,把查找路径压到极致。官方基准测试里,对字符串键的查找可以比
Dictionary 快 20%~50%。
枚举元数据这个场景完美契合它的定位:构建一次,查询万次,永不修改。如果换成
ImmutableDictionary,反而会因为树形查找结构拖慢每次访问。
3.3 防御式取值:未定义枚举值不会崩
枚举有个经典陷阱:C# 允许把任意整数值强转成枚举。
// 数据库里存了个历史遗留值 999,枚举里根本没定义
var state = (WorkImportState)999;
// 如果缓存实现写成 Cache[value].Description,这里直接 KeyNotFoundException
所以对外 API 用 TryGetValue 兜底:
public static string GetDescription<T>(this T value) where T : struct, Enum
{
// 防止传入未定义的枚举值(如 (MyEnum)999)导致 KeyNotFound 异常
if (EnumCache<T>.Cache.TryGetValue(value, out var item))
{
return item.Description;
}
return value.ToString();
}
业务侧的真实调用甚至做了双重防御:
var message = authState.GetDescription() ?? $"认证失败,错误码:{(byte)authState}";
另一个细节是 Parse 按描述反查时返回
T?(可空枚举),找不到就返回 null
而不是抛异常——解析外部输入时,“解析不出”是正常分支而非错误。
四、落地效果
以导入状态为例,整个改造前后对比:
改造前(每次调用都反射):
public static string GetDescription(this Enum value)
{
var field = value.GetType().GetField(value.ToString());
var attr = field?.GetCustomAttribute<DescriptionAttribute>();
return attr?.Description ?? value.ToString(); // 每次都扫元数据
}
改造后(首次反射,之后 O(1) 直查):
var items = EnumExtensions.ToList<WorkImportState>()
.Select(item => new SelectListItem { Text = item.Description, Value = item.Name });
// 第二次调用起,List 直接返回缓存的只读列表,零反射
ToList<T>() 返回的是初始化时就建好的
IList
只读副本,下拉框这种每次页面加载都要取一遍的场景,等于把反射成本摊薄到趋近于零。
五、总结
这套方案的关键决策可以浓缩为三句话:
- 泛型静态类是 .NET 里最被低估的缓存容器——每个封闭泛型类型独立的静态字段,配合 CLR 保证的静态构造器语义,写出来的缓存天然类型安全且线程安全;
- 读多写少选 FrozenDictionary——构建慢一点换来查询最快路径,.NET 8 起只读字典场景的首选;
- 枚举值不可信——对外 API
必须处理强转产生的未定义值,
TryGetValue+ 返回可空类型是两个低成本高收益的防御点。
反射不是不能用,而是不该反复用。把”只发生一次的事”和”发生一万次的事”分开,性能问题往往就自然消失了。