1. 从手动重复劳动说到为什么是partial范式
1.1 那些年被重复代码支配的恐惧
做业务系统做了几年之后,你会发现最让人崩溃的不是业务逻辑有多复杂,而是那些高度重复、改一处就得跟着改七八处的"连锁代码"。拿我去年参与的一个订单中台项目来说,一个订单领域对象要同时承担消息契约、数据库映射、缓存序列化、接口出入参好几重身份。开发流程是这样的:先建实体类,然后手写DTO,再写Entity到DTO的映射,再为每个DTO写JsonConverter,最后还要为所有消息类型维护一张类型名和程序集名的映射表。新同学接这种活,一天能憋出三个类就算效率高;老手倒是能写,但写着写着就开始怀疑人生——这些代码除了字段名不同,结构上几乎一模一样。
当时项目里的做法是复制粘贴,然后全局替换字段名。听起来很快,实际上隐患非常大:字段替换不干净、漏改类型、某个DTO少抄了一个属性,这些错误编译器根本发现不了,只能等联调时接口字段对不上才暴露出来。更难受的是,一旦实体基类调整,所有手写的映射代码都要跟着动,动漏一个就是线上事故。
我意识到,这个问题的本质不是"能不能写得快",而是"这类代码本身就不应该出现在源码仓库里"。它完全可以根据已有的类型定义,机械地推导出来。既然机械可推导,那就应该交给工具去生成。
1.2 为什么最后选了Source Generator而不是T4、反射或IL织入
当时的备选方案我挨个试过一遍,各有各的问题,我在下面对比一下:
| 方案 | 优势 | 实际踩到的坑 |
|---|---|---|
| T4模板 | 使用简单,VS内置 | 模板生成的代码在编译前需要手动运行或配置自定义工具;跨工程复用要引入MSBuild任务,处理起来相当繁琐;而且T4生成的代码在别的开发者机器上经常出现"没刷新"的情况 |
| 运行时反射 | 灵活,写起来快 | 性能开销一直存在;在AOT裁剪场景下,反射元数据可能直接被裁掉;出问题要等运行时才暴露,不符合我对"编译期可控"的预期 |
| IL织入(如Fody) | 运行时开销极低 | 调试链长,用户态拿到的还是原类,出错时很难定位;写自定义织入插件的资料少,团队学习成本高 |
| Source Generator | 编译期执行,代码直接进编译单元,IDE即时反馈 | 需要学习Roslyn API,初看门槛偏高 |
我最后选Source Generator,核心理由是它把"代码生成"放进了编译过程本身。也就是说,当你按下F5的时候,生成器已经跑完了,生成出来的代码和你手写的代码一起参与编译,生成的类能不能用、有没有语法错误,编译器第一时间告诉你。这一点对日常开发体验的提升是决定性的:我不需要额外记住"先运行某个工具再编译",也不需要担心哪个同事忘了执行模板更新。
1.3 partial在这里扮演的角色
确定了用Source Generator之后,紧接着要回答一个问题:生成出来的代码和手写代码怎么共存在一个类型里?总不能让生成器把整个类重新输出一遍,那样你手写的业务方法就没了。
答案是partial关键字。把目标类声明成partial class,手写部分保留业务逻辑、字段和自定义方法,生成器检测到这个类的声明之后,负责输出另外半个partial类,把那些重复性、机械性的成员填充进去。这样两半代码经过编译器天然合并,对外部调用方来说,它们就是一个完整的类,没有任何运行时代价。
这种设计精妙在什么地方?它把"程序员写的代码"和"机器生成的代码"从物理文件层面隔离开,但又在类型层面合二为一。手写部分可以随时读、随时改,生成部分每次编译都会根据当前的最新定义重新生成,不会出现"生成完一次就不动了,后面手工改生成产物"这种脏局面。我在实际项目中甚至把生成出来的文件直接排除出代码审查范围,既然它是机械产物,审查它就是在浪费时间,真正要审的是生成器本身的逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 生成器项目从0到1:工程结构与关键配置
2.1 项目类型、目标框架和依赖版本怎么选
Source Generator本质上是一个类库,但它有两个硬性要求:目标框架必须是netstandard2.0,因为生成器要跑在编译进程里,而编译进程可能是.NET Framework(旧版VS)也可能是.NET(新版VS),只有netstandard2.0能两边通吃;引用的Roslyn API必须来自Microsoft.CodeAnalysis.CSharp这个NuGet包。
我新建项目的时候用的是.NET Class Library模板,然后把TargetFramework改成netstandard2.0。注意这里的netstandard2.0指的是生成器项目本身,你的业务项目无论是.NET Framework 4.6.2还是.NET 8都能用,互不影响。这个target兼容性也是Source Generator能被广泛采用的原因之一,它不像某些需要特定运行时配合的库那样捆手捆脚。
依赖版本方面,Microsoft.CodeAnalysis.CSharp我选的是4.8.0。这里要记住一个原则:生成器引用的Roslyn版本不要太高,否则会限制使用方的IDE和SDK版本。如果你引用了最新的4.10,那你的用户必须把VS升到最新,这在实际团队里往往不现实。我见过好几个生成器库因为版本卡得太死,导致团队不敢升级。稳妥的做法是引用你目标用户基础版本之上的尽量低的版本,并且把API控制在那些长期稳定接口上。
2.2 csproj里那些容易漏的开关
起步阶段的csproj长这样:
xml复制<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
</ItemGroup>
</Project>
有两行开关你绝对不要省。第一行是<IsRoslynComponent>true</IsRoslynComponent>,它告诉IDE这个项目是Roslyn组件,VS会用对应的调试和加载逻辑对待它。没有这一行,你在调试生成器时可能会遇到加载行为异常,而且它还会影响一些内置分析规则的启用。第二行是<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>,它启用了针对Roslyn组件的一套额外代码分析规则,专门检查生成器常见误用,比如在static class里放可变的Dictionary、在生成代码里使用环境相关路径等。
PrivateAssets="all"放在Microsoft.CodeAnalysis.CSharp这个引用上也很讲究。它的作用是让Roslyn依赖不流向使用生成器的业务项目。如果你漏掉这个,业务项目在安装你的生成器包时会自动引入Roslyn程序集作为传递依赖,轻则一堆警告,重则和项目里已有的Roslyn版本产生冲突。我第一版打包时就漏了,结果所有使用方的项目都多出一堆"程序集版本冲突"警告。
2.3 Analyzer与Generator分离的设计思路
如果你只是写一个私有小工具,生成器和分析器放一起无所谓。但如果你要发布给团队甚至社区用,我强烈建议把"诊断编译器错误"和"生成代码"分开考虑,哪怕放在同一个项目,也至少要在代码目录上分开。
我在项目里建了`D
