项目里总有一类代码,写起来机械、改起来痛苦、还特别容易漏: DTO 到实体的映射、枚举与显示名的对照表、JSON 序列化器的注册。 这类代码的共同点是完全由类型结构决定,人看着就能写出来,那就应该让编译器替你写。
源生成器(Source Generator)就是干这个的:它在编译期读取你的代码,产出新的源代码文件,一起参与编译。 和运行时反射相比,它没有启动开销、对 AOT 友好、错误在编译期就能暴露。
一、工程结构:生成器是独立项目
生成器必须是一个 netstandard2.0 的类库项目,被使用者以 Analyzer 方式引用,不能作为普通程序集打进产物:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.*" PrivateAssets="all" />
</ItemGroup>
</Project>
使用方这样引用——关键是 OutputItemType="Analyzer" 与 ReferenceOutputAssembly="false":
<ItemGroup>
<ProjectReference Include="..\Mapper.Generator\Mapper.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
顺带提一句:netstandard2.0 意味着生成器里可用的 API 比较老,
System.Collections.Immutable 之类需要显式引用,写代码时会感到处处受限,这是正常的。
二、最小可用的增量生成器
增量生成器(IIncrementalGenerator)的核心思想是「管道」:
先筛出关心的语法节点,把节点转换成可比较的数据模型,最后才输出源码。
中间任何一步的输入没变化,后续步骤就不会重跑。
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
[Generator(LanguageNames.CSharp)]
public sealed class MapperGenerator : IIncrementalGenerator
{
private const string AttributeName = "Mapper.GenerateMapperAttribute";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
IncrementalValuesProvider<TypeModel> targets =
context.SyntaxProvider.ForAttributeWithMetadataName(
AttributeName,
predicate: static (node, _) => node is TypeDeclarationSyntax,
transform: static (ctx, ct) => TypeModel.Create(ctx, ct))
.Where(static m => m is not null)!;
context.RegisterSourceOutput(targets, static (spc, model) => Emit(spc, model!));
}
}
ForAttributeWithMetadataName 比手写 CreateSyntaxProvider 好得多:
它先在语法层面做一次廉价的字符串匹配,只对命中的少数节点做语义模型转换,
在大项目上能显著降低 IDE 卡顿。
三、模型必须是可比较的
这是新手最容易翻车的地方:不要把 ISymbol、SyntaxNode 这类对象往管道后面传。
它们的相等性是引用相等,每次编译都生成新实例,增量比较永远判定「变了」,于是每次按键都在全量重跑。
正确做法是把需要的字段抽出来,放进 record:
internal sealed record TypeModel(
string Namespace,
string ClassName,
EquatableArray<PropertyModel> Properties)
{
public static TypeModel? Create(GeneratorAttributeSyntaxContext ctx, CancellationToken ct)
{
if (ctx.TargetSymbol is not INamedTypeSymbol type) return null;
var props = type.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.Select(p => new PropertyModel(p.Name, p.Type.ToDisplayString()))
.ToArray();
return new TypeModel(
type.ContainingNamespace.IsGlobalNamespace ? "" : type.ContainingNamespace.ToDisplayString(),
type.Name,
new EquatableArray<PropertyModel>(props));
}
}
internal sealed record PropertyModel(string Name, string TypeName);
注意 Properties 用的是自定义的 EquatableArray<T>:
数组和 ImmutableArray 默认按引用比较,records 的自动相等性在这里等于失效。
自己包一层按元素比较的结构,才能让「内容没变」真的被识别为没变:
internal readonly struct EquatableArray<T>(T[]? items) : IEquatable<EquatableArray<T>>
where T : IEquatable<T>
{
public ReadOnlySpan<T> AsSpan() => items ?? [];
public bool Equals(EquatableArray<T> other) => AsSpan().SequenceEqual(other.AsSpan());
public override bool Equals(object? obj) => obj is EquatableArray<T> o && Equals(o);
public override int GetHashCode()
{
var hash = 17;
foreach (var item in AsSpan())
hash = unchecked(hash * 31 + item.GetHashCode());
return hash;
}
}
四、输出源码
生成代码时建议一律写全命名空间、显式 global:: 前缀、避免依赖使用方的 using,
否则很容易和用户代码里的同名词撞车:
private static void Emit(SourceProductionContext context, TypeModel model)
{
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated /> 由 MapperGenerator 生成,请勿手工修改");
sb.AppendLine("namespace " + (model.Namespace.Length == 0 ? "Generated" : model.Namespace) + ";");
sb.AppendLine();
sb.AppendLine("public static partial class " + model.ClassName + "Mapper");
sb.AppendLine("{");
foreach (var prop in model.Properties.AsSpan())
{
sb.AppendLine($" public static {prop.TypeName} Map{prop.Name}(" +
$"{model.ClassName} source) => source.{prop.Name};");
}
sb.AppendLine("}");
context.AddSource(
$"{model.ClassName}Mapper.g.cs",
SourceText.From(sb.ToString(), Encoding.UTF8));
}
文件名必须唯一,建议用「类型名 + 用途」的命名,且 hintName 里不要带路径分隔符。
生成的类最好声明为 partial,给使用方留一个手写补充的口子。
五、怎么调试生成器
生成器在编译器进程里跑,断点不像普通代码那样直接命得中。常用的两个手段:
-
把生成结果落盘——在使用方项目里加两行,生成的文件就会出现在
obj/generated, 可以直接打开看,也能被 diff:<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)generated</CompilerGeneratedFilesOutputPath> -
挂调试器——在
Initialize里加Debugger.Launch()(仅本地调试用,提交前记得删掉), 或者用 Roslyn 官方的RoslynComponent调试配置。
生成器里抛异常会直接变成编译错误,而且堆栈信息很模糊。 所以
transform里凡是「可能不成立」的判断都应当返回null由Where过滤, 真正的用户错误应该通过context.ReportDiagnostic报出来,而不是抛异常。
小结
- 生成器是独立
netstandard2.0项目,以 Analyzer 方式引用,不进产物。 - 管道三段式:筛选节点 → 转成 record 模型 → 输出源码,中间不要传 symbol。
- 集合模型要自己实现按值比较,否则增量缓存形同虚设。
- 生成的代码写全限定名,
hintName唯一,落盘 +Debugger.Launch是排障利器。