用源生成器消灭重复代码:从 IIncrementalGenerator 开始

项目里总有一类代码,写起来机械、改起来痛苦、还特别容易漏: 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 是排障利器。
上一篇 CancellationToken 协作式取消的正确姿势