mcpField
D
dotnet-csharp-source-generators

dotnet-csharp-source-generators

github.com/wshaddix开发8682 次安装35 周前更新
代码仓库

关于 dotnet-csharp-source-generators

面向 .NET 开发者的 Roslyn 源生成器技能,涵盖生成器的创建与使用。内容包括增量流水线、诊断、测试、打包,以及正则表达式、日志和 JSON 内置生成器。

主要功能

  1. 01增量生成器流水线指引
  2. 02语法与语义分析模式
  3. 03诊断报告与代码生成
  4. 04使用 CSharpGeneratorDriver 测试生成器
  5. 05正则、日志和 JSON 内置生成器

适用场景

  1. 01实现 Roslyn 源生成器
  2. 02在内存中测试生成代码
  3. 03通过源生成实现 JSON 序列化

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/wshaddix/dotnet-skills --skill dotnet-csharp-source-generators

安装后在 Claude Code 中输入/dotnet-csharp-source-generators确认已加载。

详细介绍

dotnet-csharp-source-generators

为在 .NET 中创建和使用 Roslyn 源生成器提供指导。创建:IIncrementalGenerator、语法提供程序、语义分析、代码生成模式、诊断报告,以及使用 CSharpGeneratorDriver 进行测试。使用:[GeneratedRegex]、[LoggerMessage]、System.Text.Json 源生成,以及 [JsonSerializable]。

交叉引用:[skill:dotnet-csharp-modern-patterns],了解 partial 属性及相关 C# 功能;[skill:dotnet-csharp-coding-standards],了解命名约定。


创建源生成器

项目设置

源生成器以 netstandard2.0 为目标框架,并作为分析器发布。

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
    <LangVersion>latest</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
  </ItemGroup>
</Project>

始终以 netstandard2.0 为目标框架。 生成器会加载到编译器进程中,因此需要使用此 TFM 以确保兼容性。使用 LangVersion>latest,即可在生成器本身中编写现代 C#。

IIncrementalGenerator(首选)

始终优先使用 IIncrementalGenerator,而不是旧版 ISourceGenerator。增量生成器能够感知缓存,仅在输入发生变化时重新运行,因此在 IDE 场景中速度显著更快。

[Generator]
public sealed class AutoNotifyGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: Filter syntax nodes to candidate fields
        var fieldDeclarations = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyLib.AutoNotifyAttribute",
                predicate: static (node, _) => node is FieldDeclarationSyntax,
                transform: static (ctx, _) => GetFieldInfo(ctx))
            .Where(static info => info is not null)
            .Select(static (info, _) => info!.Value);

        // Step 2: Group fields by containing type, then emit one file per type
        context.RegisterSourceOutput(fieldDeclarations.Collect(),
            static (spc, fields) => Execute(fields, spc));
    }

    private static FieldInfo? GetFieldInfo(
        GeneratorAttributeSyntaxContext context)
    {
        var fieldSymbol = context.TargetSymbol as IFieldSymbol;
        if (fieldSymbol is null)
            return null;

        var containingType = fieldSymbol.ContainingType;

        // Use fully qualified type name to handle generic and nested types
        var fullTypeName = containingType.ToDisplayString(
            SymbolDisplayFormat.FullyQualifiedFormat
                .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted));

        return new FieldInfo(
            fieldSymbol.ContainingNamespace.IsGlobalNamespace
                ? ""
                : fieldSymbol.ContainingNamespace.ToDisplayString(),
            containingType.Name,
            fullTypeName,
            fieldSymbol.Name,
            fieldSymbol.Type.ToDisplayString());
    }

    private static void Execute(
        ImmutableArray<FieldInfo> fields,
        SourceProductionContext context)
    {
        // Group by fully qualified type name to emit one file per class
        foreach (var group in fields.GroupBy(f => f.FullTypeName))
        {
            var first = group.First();
            var ns = first.Namespace;
            var className = first.ClassName;
            var properties = new StringBuilder();

            foreach (var field in group)
            {
                var propertyName = GetPropertyName(field.FieldName);
                properties.AppendLine($$"""
                        public {{field.FieldType}} {{propertyName}}
                        {
                            get => {{field.FieldName}};
                            set
                            {
                                if (!global::System.Collections.Generic.EqualityComparer<{{field.FieldType}}>.Default.Equals({{field.FieldName}}, value))
                                {
                                    {{field.FieldName}} = value;
                                    PropertyChanged?.Invoke(this,
                                        new global::System.ComponentModel.PropertyChangedEventArgs(nameof({{propertyName}})));
                                }
                            }
                        }
                    """);
            }

            // Handle global namespace (no namespace declaration)
            var nsBlock = string.IsNullOrEmpty(ns) ? "" : $"namespace {ns};\n\n";

            var source = $$"""
                // <auto-generated/>
                #nullable enable

                {{nsBlock}}partial class {{className}}
                    : global::System.ComponentModel.INotifyPropertyChanged
                {
                    public event global::System.ComponentModel.PropertyChangedEventHandler? PropertyChanged;

                {{properties}}
                }
                """;

            // Include namespace in hint name to avoid collisions across namespaces
            var hintPrefix = string.IsNullOrEmpty(ns) ? className : $"{ns}.{className}";
            context.AddSource($"{hintPrefix}.AutoNotify.g.cs", source);
        }
    }

    private static string GetPropertyName(string fieldName)
        => fieldName.TrimStart('_') is [var first, .. var rest]
            ? $"{char.ToUpperInvariant(first)}{rest}"
            : fieldName;
}

internal readonly record struct FieldInfo(
    string Namespace,
    string ClassName,
    string FullTypeName,
    string FieldName,
    string FieldType);

范围说明: 为便于说明,此示例针对顶层、非泛型类。生产级生成器还应处理泛型类型参数(生成匹配的 partial class Foo<T> 声明)和嵌套类型(生成嵌套的 partial 类层次结构)。对于不支持的类型,应报告诊断,而不是生成无效代码。

关键管道设计规则
  1. 尽早筛选——使用 ForAttributeWithMetadataName 或配合严格谓词的 CreateSyntaxProvider,尽量减少工作量。
  2. 转换为简单数据——在转换步骤中仅提取所需数据(字符串、记录)。不要在管道中传递 ISymbol 或 SyntaxNode(它们会让编译对象保持存活,并破坏缓存)。
  3. 使用值相等性——管道输出会按值进行比较。对于自定义类型,请使用 record struct 或实现 IEquatable<T>。
  4. 生成确定性输出——相同输入必须生成相同源代码。使用 // <auto-generated/> 和 #nullable enable 标头。
语法提供程序
// ForAttributeWithMetadataName -- most common, filters by attribute
var candidates = context.SyntaxProvider.ForAttributeWithMetadataName(
    "MyLib.GenerateMapperAttribute",
    predicate: static (node, _) => node is ClassDeclarationSyntax,
    transform: static (ctx, _) => /* extract info */);

// CreateSyntaxProvider -- general-purpose, any syntax predicate
var candidates = context.SyntaxProvider.CreateSyntaxProvider(
    predicate: static (node, _) => node is MethodDeclarationSyntax m
        && m.Modifiers.Any(SyntaxKind.PartialKeyword),
    transform: static (ctx, _) => /* extract info */);
诊断报告

通过 SourceProductionContext 报告错误和警告,而不是抛出异常。若要报告特定位置的诊断,请在管道数据中包含 Location(在转换步骤中从语法节点捕获)。

private static readonly DiagnosticDescriptor InvalidFieldType = new(
    id: "AN001",
    title: "Invalid field type for AutoNotify",
    messageFormat: "Field '{0}' must be a non-pointer type",
    category: "AutoNotify",
    defaultSeverity: DiagnosticSeverity.Error,
    isEnabledByDefault: true);

// In the transform step, capture location:
var location = context.TargetNode.GetLocation();

// In the Execute method, report with location:
context.ReportDiagnostic(Diagnostic.Create(
    InvalidFieldType,
    location,       // captured from syntax node, not from projected data
    fieldName));

注意: Location 不支持值相等性,因此将其包含在管道记录中会破坏增量缓存。常见做法是将其作为单独字段传递并从相等性比较中排除,或者在投影为值类型之前,于 CreateSyntaxProvider 步骤中报告诊断。

代码生成模式
// Prefer raw string literals for templates (C# 11+, in the generator project)
var source = $$"""
    // <auto-generated/>
    #nullable enable

    namespace {{ns}};

    partial class {{className}}
    {
        {{generatedMembers}}
    }
    """;

context.AddSource($"{className}.g.cs", source);

文件命名约定: {TypeName}.{Feature}.g.cs——.g.cs 后缀表示生成的代码,许多代码检查工具会排除这类文件。

后初始化输出(静态源代码)

对于不依赖用户代码的标记属性和辅助类型,请使用 RegisterPostInitializationOutput:

context.RegisterPostInitializationOutput(static ctx =>
{
    ctx.AddSource("AutoNotifyAttribute.g.cs", """
        // <auto-generated/>
        namespace MyLib;

        [System.AttributeUsage(System.AttributeTargets.Field)]
        internal sealed class AutoNotifyAttribute : System.Attribute { }
        """);
});

测试源生成器

使用 CSharpGeneratorDriver 在内存中运行生成器并验证输出。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;

[Fact]
public void Generator_ProducesExpectedOutput()
{
    // Arrange
    var source = """
        using MyLib;

        namespace TestApp;

        public partial class ViewModel
        {
            [AutoNotify]
            private string _name = "";
        }
        """;

    var syntaxTree = CSharpSyntaxTree.ParseText(source);
    var references = AppDomain.CurrentDomain.GetAssemblies()
        .Where(a => !a.IsDynamic && !string.IsNullOrEmpty(a.Location))
        .Select(a => MetadataReference.CreateFromFile(a.Location))
        .Cast<MetadataReference>()
        .ToList();

    var compilation = CSharpCompilation.Create("TestAssembly",
        [syntaxTree],
        references,
        new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

    var generator = new AutoNotifyGenerator();

    // Act
    GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
    driver = driver.RunGeneratorsAndUpdateCompilation(
        compilation, out var outputCompilation, out var diagnostics);

    // Assert
    Assert.Empty(diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error));

    var runResult = driver.GetRunResult();
    Assert.Single(runResult.GeneratedTrees);

    var generatedSource = runResult.GeneratedTrees[0].GetText().ToString();
    Assert.Contains("public string Name", generatedSource);
}
快照测试(Verify)

如需更可靠的测试,请使用 Verify.SourceGenerators 包对生成的输出进行快照测试:

[Fact]
public Task Generator_SnapshotTest()
{
    var source = """
        using MyLib;
        namespace TestApp;
        public partial class ViewModel
        {
            [AutoNotify]
            private string _name = "";
        }
        """;

    return TestHelper.Verify(source);
}

使用内置源生成器

[GeneratedRegex](net7.0+)

在编译时生成正则表达式。运行时无需编译开销,并且兼容 AOT。

public partial class Validators
{
    [GeneratedRegex(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$",
        RegexOptions.Compiled | RegexOptions.IgnoreCase)]
    private static partial Regex EmailRegex();

    public static bool IsValidEmail(string email)
        => EmailRegex().IsMatch(email);
}

关键规则:

  • 方法必须是返回 Regex 的 static partial 方法
  • 应放在 partial class(或 partial struct)上
  • 可替代 new Regex(...),运行时无需分配
  • 支持除 RegexOptions.Compiled 之外的所有 RegexOptions(该选项会被忽略,因为源生成器会取代它)
[LoggerMessage] (net6.0+)

在日志级别禁用时实现零分配的高性能结构化日志记录。

public static partial class LogMessages
{
    [LoggerMessage(Level = LogLevel.Information,
        Message = "Processing order {OrderId} for customer {CustomerId}")]
    public static partial void OrderProcessing(
        this ILogger logger, int orderId, string customerId);

    [LoggerMessage(Level = LogLevel.Error,
        Message = "Failed to process order {OrderId}")]
    public static partial void OrderProcessingFailed(
        this ILogger logger, int orderId, Exception exception);
}

// Usage
logger.OrderProcessing(order.Id, order.CustomerId);

主要规则:

  • 方法必须在 partial class 中声明为 static partial
  • 与消息中的 {Placeholder} 匹配的参数会作为结构化数据记录
  • Exception 参数会自动记录(不要将其包含在消息模板中)
  • 如果未指定事件 ID,则会自动分配;如需稳定的遥测数据,请显式指定 ID
System.Text.Json 源生成(net6.0+)

与 AOT 兼容的 JSON 序列化。消除运行时反射。

[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
[JsonSerializable(typeof(Customer))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class AppJsonContext : JsonSerializerContext;
在 ASP.NET Core 中注册
builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});

// Or for Minimal APIs
app.MapGet("/orders/{id}", async (int id, IOrderService service) =>
{
    var order = await service.GetByIdAsync(id);
    return order is not null
        ? Results.Ok(order)
        : Results.NotFound();
});
手动序列化
// Serialize
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);

// Deserialize
var order = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);

// With stream
await JsonSerializer.SerializeAsync(stream, orders,
    AppJsonContext.Default.ListOrder);

主要规则:

  • 在 [JsonSerializable] 特性中注册所有需要序列化的类型
  • 使用 TypeInfoResolverChain(net8.0+)组合多个上下文
  • Native AOT 必须使用此方式——基于反射的序列化会被修剪
  • 有关生成代码中使用的相关 C# 功能,请参阅 [skill:dotnet-csharp-modern-patterns]
使用多态的 [JsonSerializable](net7.0+)
[JsonDerivedType(typeof(CreditCardPayment), "credit")]
[JsonDerivedType(typeof(BankTransferPayment), "bank")]
public abstract class Payment
{
    public decimal Amount { get; init; }
}

public class CreditCardPayment : Payment
{
    public required string CardLast4 { get; init; }
}

public class BankTransferPayment : Payment
{
    public required string AccountNumber { get; init; }
}

[JsonSerializable(typeof(Payment))]
public partial class PaymentJsonContext : JsonSerializerContext;

生成器参考:打包与使用

在使用方项目中引用生成器
<ItemGroup>
  <ProjectReference Include="..\MyGenerator\MyGenerator.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>
NuGet 包布局

将生成器作为 NuGet 包分发时,请将程序集放在 analyzers/dotnet/cs/ 下:

MyGenerator.nupkg
  analyzers/
    dotnet/
      cs/
        MyGenerator.dll
  lib/
    netstandard2.0/
      _._   (empty placeholder if no runtime dependency)

<PropertyGroup>
  <IncludeBuildOutput>false</IncludeBuildOutput>
  <DevelopmentDependency>true</DevelopmentDependency>
</PropertyGroup>

<ItemGroup>
  <None Include="$(OutputPath)\$(AssemblyName).dll"
        Pack="true"
        PackagePath="analyzers/dotnet/cs" />
</ItemGroup>

调试源生成器

// Add to Initialize() for attach-debugger workflow
#if DEBUG
if (!System.Diagnostics.Debugger.IsAttached)
{
    System.Diagnostics.Debugger.Launch();
}
#endif

或者,将生成的文件输出到磁盘以供检查:


<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

将 Generated/ 添加到 .gitignore。


参考资料

评论

支持 Markdown