关于 dotnet-csharp-source-generators
面向 .NET 开发者的 Roslyn 源生成器技能,涵盖生成器的创建与使用。内容包括增量流水线、诊断、测试、打包,以及正则表达式、日志和 JSON 内置生成器。
主要功能
- 01增量生成器流水线指引
- 02语法与语义分析模式
- 03诊断报告与代码生成
- 04使用 CSharpGeneratorDriver 测试生成器
- 05正则、日志和 JSON 内置生成器
适用场景
- 01实现 Roslyn 源生成器
- 02在内存中测试生成代码
- 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 类层次结构)。对于不支持的类型,应报告诊断,而不是生成无效代码。
关键管道设计规则
- 尽早筛选——使用
ForAttributeWithMetadataName或配合严格谓词的CreateSyntaxProvider,尽量减少工作量。 - 转换为简单数据——在转换步骤中仅提取所需数据(字符串、记录)。不要在管道中传递
ISymbol或SyntaxNode(它们会让编译对象保持存活,并破坏缓存)。 - 使用值相等性——管道输出会按值进行比较。对于自定义类型,请使用
record struct或实现IEquatable<T>。 - 生成确定性输出——相同输入必须生成相同源代码。使用
// <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。