一、一个每天都在写、也每天都在出错的样板代码

.NET 项目里给枚举挂中文描述,标准写法是加 Description 特性,然后靠反射读出来:

public enum SparePartStatus
{
[Description("草稿")] Draft,
[Description("已提交")] Submitted,
[Description("审批中")] Approving,
[Description("已驳回")] Rejected,
}

public static string GetDescription(this Enum value)
{
var field = value.GetType().GetField(value.ToString());
var attr = field?.GetCustomAttribute<DescriptionAttribute>();
return attr?.Description ?? value.ToString();
}

三个问题在实际项目里反复出现:

  1. 每次调用都走一次特性查找,于是又加一个 ConcurrentDictionary 缓存;
  2. 缓存要预热,冷启动第一批请求仍然会打反射;
  3. Native AOT 下 GetCustomAttribute 可能被裁剪掉,编译不报错,运行时静默返回 null——排查起来最难的一类问题。

第三点是决定性的。 reflection 方案在 AOT 场景基本不可用,而源生成器方案天然兼容,因为它没有需要裁剪的反射依赖。

二、换成编译期生成,形态是什么样

Roslyn 提供 IIncrementalGenerator,可以在编译期间分析语法树,直接往项目里注入生成好的 C# 代码。思路是:扫描带特定特性的枚举,生成一个 switch 表达式映射。

// 生成结果(示意)
public static partial class SparePartStatusDescription
{
public static string ToDisplay(this SparePartStatus value) => value switch
{
SparePartStatus.Draw => "草稿",
SparePartStatus.Submitted => "已提交",
SparePartStatus.Approving => "审批中",
SparePartStatus.Rejected => "已驳回",
_ => value.ToString()
};
}

收益是三层:

  • 运行时开销归零。生成的是编译时常量折叠的 switch,JIT 出来的机器码基本就是几条比较加跳转,没有字典查找;
  • AOT 天然可用;
  • 漏项在编译期暴露。反射方案里”新加一个枚举值忘了写描述”只能靠线上返回 null 才发现;生成器可以在检测到未覆盖成员时报编译警告——这条比性能收益值钱得多。

三、生成器骨架

[Generator]
public sealed class EnumDescriptionGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var enums = context.SyntaxProvider
.ForPostInitializationCompilationUnit // 略:筛选带特性的 enum 声明
.Collect();

context.RegisterSourceOutput(enums, (spc, list) =>
{
foreach (var e in list) spc.AddHint(e.FullName + ".g.cs", Render(e));
});
}
}

工程上三条注意事项,都是踩过的位置:

  1. 用 IIncrementalGenerator 而不是老的 ISourceGenerator。 增量版本有缓存,输入不变就不重跑;在大型解决方案里这个差距是编译时间级别的。
  2. **生成文件加 .g.cs 后缀并标记 // <auto-generated />**,让 IDE 与代码统计跳过它。
  3. 不要让生成器去读运行时才能拿到的信息。 它只能看语法树和编译模型;需要动态内容的映射,不适合这一路。

四、语言版本这条线,别再猜

顺便厘清几个常被混着说的特性,免得写文档时出错:

  • 源生成器自 Roslyn 4 起进入正式支持,Visual Studio 2022 时代可用;
  • 正则表达式源生成器自 .NET 7 起可用([GeneratedRegex] 配合 partial 方法);
  • partial 属性是 C# 13 引入的,主要目的就是支持源生成器;
  • C# 14 / .NET 10 又把 partial 扩展到事件与构造函数,并引入 extension members、field 关键字等。

也就是说:让源生成器”往你的类型上添成员”这件事,语言层面一直在为它铺路。枚举描述只是最容易下手的一个例子。

编译期生成的枚举映射

五、洹拓视角:什么时候值得为它建一个工程

坦白说,为一个枚举描述专门写生成器是过度工程。判断标准我们只看两条:

值得做的情况: 项目里存在几十个这样的枚举(订单状态、审批状态、库存类型、备件类型……MES/ERP 类系统非常容易出现);或者已经有 Native AOT / 裁剪 / 移动端启动性能约束;或者你吃过”新增枚举值忘了配文案导致界面显示空白”的线上问题。

不值得做的情况: 三五个枚举、没有 AOT 需求、也不在性能敏感路径上。这时候一个手写 switch 加一条 code review 规则就够了——引入生成器等于引入一个新的构建环节和一个团队必须学习的概念,它的成本要算进决策里。

我们在给客户做架构评估时,会把”是否值得建源生成器工程”归类为规模化才划算的基础设施投入,和大仓的构建缓存是同一档判断。豫北这边不少软件公司做MES、WMS 定制,几十上百个状态枚举是常态,这一类改造的投入产出比通常是正的。


反射方案与源生成器方案的差异、AOT 裁剪行为及 C# 各版本 partial 相关特性均与公开文档和社区实现核对;选型门槛为洹拓科技自身判断。

新乡洹拓科技有限公司,软件开发 · AI模型研发 · 云服务与部署运维。

咨询热线:400-829-9957
地址:河南省新乡市辉县市金城百货四楼