枚举转中文描述是业务系统的刚需:状态字段要显示”成功/失败”而不是 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&lt;T&gt;()"] --> B{"EnumCache&lt;T&gt;.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 互不干扰。

这带来三个好处:

  1. Key 编译期确定:不需要 ConcurrentDictionary<Type, object> 这种以 Type 为键的字典,也就没有装箱和强转;
  2. 线程安全免费拿:静态构造器由 CLR 保证在类型首次使用前执行且只执行一次,天然线程安全,不需要 Lazy<T> 或双检锁;
  3. 类型安全: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 只读副本,下拉框这种每次页面加载都要取一遍的场景,等于把反射成本摊薄到趋近于零。

五、总结

这套方案的关键决策可以浓缩为三句话:

  1. 泛型静态类是 .NET 里最被低估的缓存容器——每个封闭泛型类型独立的静态字段,配合 CLR 保证的静态构造器语义,写出来的缓存天然类型安全且线程安全;
  2. 读多写少选 FrozenDictionary——构建慢一点换来查询最快路径,.NET 8 起只读字典场景的首选;
  3. 枚举值不可信——对外 API 必须处理强转产生的未定义值,TryGetValue + 返回可空类型是两个低成本高收益的防御点。

反射不是不能用,而是不该反复用。把”只发生一次的事”和”发生一万次的事”分开,性能问题往往就自然消失了。