- Summary
- Proposal
- Out of scope designs
- Conventions
- Designs
- Generated class
- Additional file transformation
- Augment user code
- Issue Diagnostics
- INotifyPropertyChanged
- Package a generator as a NuGet package
- Use functionality from NuGet packages
- Access Analyzer Config properties
- Consume MSBuild properties and metadata
- Unit Testing of Generators
- Participate in the IDE experience
- Serialization
- Auto interface implementation
- Breaking Changes:
- Open Issues
https://github.com/dotnet/roslyn/blob/main/docs/features/source-generators.cookbook.md
原文章可能还会更新(此翻译基于2022/01/07的版本)。
例子:https://github.com/dotnet/roslyn-sdk/tree/main/samples/CSharp/SourceGenerators
Summary
Note: 源生成器提案的设计仍在审查中。本文档仅使用一种可能的语法,预计会随着功能的发展而更改,但不会另行通知。
本文档旨在通过提供一系列常见模式指南来帮助创建源生成器。它还旨在列出在当前设计下哪些类型的生成器是可能的,以及已经发布的功能的最终设计中预计会明确超出范围的是什么。
本文档详细介绍了完整的设计文档,请确保您已先阅读该文档。
Proposal
提醒一下,源生成器的高级设计目标是:
- 生成器生成一个或多个字符串,这些字符串表示要添加到编译中的C#源代码
- 仅限明确的可加性。生成器可以将新源代码添加到编译中,但不能修改现有用户代码。
- 可以产生诊断结果。当无法生成源代码时,生成器可以将问题通知用户。
- 可以访问其他文件,即非C#源文本。
- 无序运行,每个生成器将看到相同的输入编译,无法访问其他源生成器创建的文件。
-
Out of scope designs
超出范围的设计
我们将简要介绍无法解决的问题,将其作为源生成器不能解决的问题的示例:Language features
源代码生成器并不是用来替换新的语言特性的:例如,您可以想象将records实现为将指定的语法转换为可编译的C#表示的源代码生成器。
我们明确地认为这是一种反模式;该语言将继续发展并添加新特性,我们不期望源代码生成器能够实现这一点。这样做会创建与没有生成器的编译器不兼容的C#的新“方言”。此外,由于生成器在设计上不能彼此交互,因此以这种方式实现的语言功能将很快与该语言的其他附加功能不兼容。Code rewriting
目前,用户在其程序集上执行的后处理任务有很多,这里我们广义地将其定义为“代码重写”。这些包括但不限于:
Optimization 优化
- Logging injection 日志注入
- IL Weaving IL织造
- Call site re-writing
虽然这些技术有许多有价值的用例,但它们不符合源代码生成的思想。根据定义,它们是源生成器提案明确排除的代码更改操作。
已经有得到很好支持的工具和技术来实现这些类型的操作,而源生成器提案的目的并不是要取代它们。
Conventions
约定
TODO:列出一组适用于下面所有设计的一般约定。例如重用名称空间、生成的文件名等。
Designs
本节按用户场景进行细分,首先列出一般解决方案,然后列出更具体的示例。
Generated class
User scenario: 作为生成器作者,我希望能够向编译中添加可由用户代码引用的类型。
Solution: 让用户编写代码,就好像类型已经存在一样。根据编译中提供的信息生成缺少的类型。
Example:
Given the following user code:
public partial class UserClass
{
public void UserMethod()
{
// call into a generated method
GeneratedNamespace.GeneratedClass.GeneratedMethod();
}
}
Create a generator that will create the missing type when run:
[Generator]
public class CustomGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context) {}
public void Execute(GeneratorExecutionContext context)
{
context.AddSource("myGeneratedFile.cs", SourceText.From(@"
namespace GeneratedNamespace
{
public class GeneratedClass
{
public static void GeneratedMethod()
{
// generated code
}
}
}", Encoding.UTF8));
}
}
Additional file transformation
其他文件转换
User scenario: 作为生成器作者,我希望能够将外部非C#文件转换为等效的C#表示。
Solution: 使用GeneratorExecutionContext
的Additional Files属性检索文件内容,将其转换为C#表示形式并返回。
Example:
[Generator]
public class FileTransformGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context) {}
public void Execute(GeneratorExecutionContext context)
{
// find anything that matches our files
var myFiles = context.AnalyzerOptions.AdditionalFiles.Where(at => at.Path.EndsWith(".xml"));
foreach (var file in myFiles)
{
var content = file.GetText(context.CancellationToken);
// do some transforms based on the file context
string output = MyXmlToCSharpCompiler.Compile(content);
var sourceText = SourceText.From(output, Encoding.UTF8);
context.AddSource($"{file.Name}generated.cs", sourceText);
}
}
}
Augment user code
增加用户代码
User scenario: 作为一名生成器作者,我希望能够使用新功能检查和增强用户的代码。
Solution: 要求用户将您要增加的类设置为partial class
,并使用唯一属性或名称等进行标记。注册一个SyntaxReceiver
,它查找任何标记为要生成的类并记录它们。在生成阶段检索填充的SyntaxReceiver
,并使用记录的信息生成包含附加功能的匹配部分类。
Example:
public partial class UserClass
{
public void UserMethod()
{
// call into a generated method inside the class
this.GeneratedMethod();
}
}
[Generator]
public class AugmentingGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// Register a factory that can create our custom syntax receiver
context.RegisterForSyntaxNotifications(() => new MySyntaxReceiver());
}
public void Execute(GeneratorExecutionContext context)
{
// the generator infrastructure will create a receiver and populate it
// we can retrieve the populated instance via the context
MySyntaxReceiver syntaxReceiver = (MySyntaxReceiver)context.SyntaxReceiver;
// get the recorded user class
ClassDeclarationSyntax userClass = syntaxReceiver.ClassToAugment;
if (userClass is null)
{
// if we didn't find the user class, there is nothing to do
return;
}
// add the generated implementation to the compilation
SourceText sourceText = SourceText.From($@"
public partial class {userClass.Identifier}
{{
private void GeneratedMethod()
{{
// generated code
}}
}}", Encoding.UTF8);
context.AddSource("UserClass.Generated.cs", sourceText);
}
class MySyntaxReceiver : ISyntaxReceiver
{
public ClassDeclarationSyntax ClassToAugment { get; private set; }
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
// Business logic to decide what we're interested in goes here
if (syntaxNode is ClassDeclarationSyntax cds &&
cds.Identifier.ValueText == "UserClass")
{
ClassToAugment = cds;
}
}
}
}
Issue Diagnostics
问题诊断
User Scenario: 作为生成器作者,我希望能够将诊断添加到用户编译中。
Solution: 可以通过GeneratorExecutionContext.ReportDiagnostic()
将诊断添加到编译中。这些可能是对用户编译内容的响应:例如,如果生成器期望一个格式良好的AdditionalFile
,但无法解析它,则生成器可能会发出警告,通知用户生成无法继续。
对于基于代码的问题,生成器作者还应该考虑实现一个诊断分析器(diagnostic analyzer)来识别问题,并提供代码修复来解决问题。
Example:
[Generator]
public class MyXmlGenerator : ISourceGenerator
{
private static readonly DiagnosticDescriptor InvalidXmlWarning = new DiagnosticDescriptor(id: "MYXMLGEN001",
title: "Couldn't parse XML file",
messageFormat: "Couldn't parse XML file '{0}'.",
category: "MyXmlGenerator",
DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public void Execute(GeneratorExecutionContext context)
{
// Using the context, get any additional files that end in .xml
IEnumerable<AdditionalText> xmlFiles = context.AdditionalFiles.Where(at => at.Path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase));
foreach (AdditionalText xmlFile in xmlFiles)
{
XmlDocument xmlDoc = new XmlDocument();
string text = xmlFile.GetText(context.CancellationToken).ToString();
try
{
xmlDoc.LoadXml(text);
}
catch (XmlException)
{
// issue warning MYXMLGEN001: Couldn't parse XML file '<path>'
context.ReportDiagnostic(Diagnostic.Create(InvalidXmlWarning, Location.None, xmlFile.Path));
continue;
}
// continue generation...
}
}
public void Initialize(GeneratorInitializationContext context)
{
}
}
INotifyPropertyChanged
User scenario: 作为生成器作者,我希望能够为用户自动实现INotifyPropertyChanged
模式。
Solution: 设计租户“显式添加的”似乎与实现这一点的能力直接冲突,并且似乎需要修改用户代码。但是,我们可以利用显式字段,而不是编辑用户属性,直接为列出的字段提供它们。
Example:
Given a user class such as:
using AutoNotify;
public partial class UserClass
{
[AutoNotify]
private bool _boolProp;
[AutoNotify(PropertyName = "Count")]
private int _intProp;
}
A generator could produce the following:
using System;
using System.ComponentModel;
namespace AutoNotify
{
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
sealed class AutoNotifyAttribute : Attribute
{
public AutoNotifyAttribute()
{
}
public string PropertyName { get; set; }
}
}
public partial class UserClass : INotifyPropertyChanged
{
public bool BoolProp
{
get => _boolProp;
set
{
_boolProp = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("UserBool"));
}
}
public int Count
{
get => _intProp;
set
{
_intProp = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Count"));
}
}
public event PropertyChangedEventHandler PropertyChanged;
}
Package a generator as a NuGet package
将生成器打包为NuGet包
User scenario: 作为生成器作者,我想将我的生成器打包为NuGet包以供使用。
Solution: 生成器可以使用与分析器相同的方法打包。确保生成器放置在软件包的analyzers\dotnet\cs
文件夹中,以便在安装时自动添加到用户项目中。
例如,要在生成时将生成器项目转换为NuGet包,请将以下内容添加到项目文件中:
<PropertyGroup>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild> <!-- Generates a package at build -->
<IncludeBuildOutput>false</IncludeBuildOutput> <!-- Do not include the generator as a lib dependency -->
</PropertyGroup>
<ItemGroup>
<!-- Package the generator in the analyzer directory of the nuget package -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
Use functionality from NuGet packages
使用NuGet包中的功能
User Scenario: 作为生成器作者,我希望依赖生成器内部的NuGet包中提供的功能。
Solution: 可以依赖于生成器内部的NuGet包,但在分发时必须特别考虑。
任何运行时依赖项,即最终用户程序需要依赖的代码,都可以通过通常的引用机制简单地作为生成器NuGet包的依赖项添加。
例如,考虑一个生成器,它创建依赖Newtonsoft.Json
的代码。生成器不直接使用依赖项,它只是发出依赖于用户编译中引用的库的代码。作者将添加一个对Newtonsoft.Json
的引用作为公共依赖项,当用户添加生成器包时,它将自动引用。
生成器可以检查编译中是否存在Newtonsoft.Json
程序集,如果不存在则发出警告或错误。
<Project>
<PropertyGroup>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild> <!-- Generates a package at build -->
<IncludeBuildOutput>false</IncludeBuildOutput> <!-- Do not include the generator as a lib dependency -->
</PropertyGroup>
<ItemGroup>
<!-- Take a public dependency on Json.Net. Consumers of this generator will get a reference to this package -->
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" />
<!-- Package the generator in the analyzer directory of the nuget package -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>
using System.Linq;
[Generator]
public class SerializingGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
// check that the users compilation references the expected library
if (!context.Compilation.ReferencedAssemblyNames.Any(ai => ai.Name.Equals("Newtonsoft.Json", StringComparison.OrdinalIgnoreCase)))
{
context.ReportDiagnostic(/*error or warning*/);
}
}
public void Initialize(GeneratorInitializationContext context)
{
}
}
但是,任何生成时依赖项,即生成器在运行和生成代码时使用的依赖项,都必须直接与生成器程序集一起打包到生成器NuGet包中。没有用于此操作的自动工具,您需要手动指定要包含的依赖项。
考虑一个生成器,该生成器在生成过程中使用Newtonsoft.Json
将某些内容编码到json,但是不运行任何依赖于它在运行时存在的代码。作者将添加对Newtonsoft.Json
的引用,但将其所有资产设为私有;这确保生成器的使用者不会继承对库的依赖。
然后,作者必须将Newtonsoft.Json
库与生成器一起打包到NuGet包中。这可以通过以下方式实现:通过添加GeneratePathProperty="true"
来设置依赖项以生成路径属性。这将创建格式为PKG<PackageName>
的新MSBuild属性,其中<PackageName>
是具有的包名.
替换为_
。在我们的示例中,将有一个名为PKGNewtonsoft_Json
的MSBuild属性,其值指向NuGet文件的二进制内容在磁盘上的路径。然后,我们可以使用它将二进制文件添加到生成的NuGet包中,就像我们对生成器本身所做的那样:
<Project>
<PropertyGroup>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild> <!-- Generates a package at build -->
<IncludeBuildOutput>false</IncludeBuildOutput> <!-- Do not include the generator as a lib dependency -->
</PropertyGroup>
<ItemGroup>
<!-- Take a private dependency on Newtonsoft.Json (PrivateAssets=all) Consumers of this generator will not reference it.
Set GeneratePathProperty=true so we can reference the binaries via the PKGNewtonsoft_Json property -->
<PackageReference Include="Newtonsoft.Json" Version="12.0.1" PrivateAssets="all" GeneratePathProperty="true" />
<!-- Package the generator in the analyzer directory of the nuget package -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<!-- Package the Newtonsoft.Json dependency alongside the generator assembly -->
<None Include="$(PkgNewtonsoft_Json)\lib\netstandard2.0\*.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>
[Generator]
public class JsonUsingGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
// use the newtonsoft.json library, but don't add any source code that depends on it
var serializedContent = Newtonsoft.Json.JsonConvert.SerializeObject(new { a = "a", b = 4 });
context.AddSource("myGeneratedFile.cs", SourceText.From($@"
namespace GeneratedNamespace
{{
public class GeneratedClass
{{
public static const SerializedContent = {serializedContent};
}}
}}", Encoding.UTF8));
}
public void Initialize(GeneratorInitializationContext context)
{
}
}
Access Analyzer Config properties
访问分析器配置属性
Implementation status: Available from VS 16.7 preview3.
User Scenarios:
- 作为生成器作者,我希望访问语法树或附加文件的分析器配置属性。
- 作为生成器作者,我希望访问自定义生成器输出的键-值对。
- 作为生成器的用户,我希望能够自定义生成的代码并覆盖默认值。
Solution: 生成器可以通过GeneratorExecutionContext
的AnalyzerConfigOptions
属性访问分析器配置值。Analyzer配置值可以在语法树(SyntaxTree)
、AdditionalFile
的上下文中访问,也可以通过GlobalOptions
全局访问。全局选项是“环境”的,因为它们不适用于任何特定上下文,但在特定上下文中请求选项时将包括在内。
生成器可以自由使用全局选项来自定义其输出。例如,考虑一个可以选择性地发出日志记录的生成器。作者可以选择检查全局分析器配置值的值,以便控制是否发出日志记录代码。然后,用户可以选择通过.editorconfig
文件启用每个项目的设置:
mygenerator_emit_logging = true
[Generator]
public class MyGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
// control logging via analyzerconfig
bool emitLogging = false;
if (context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("mygenerator_emit_logging", out var emitLoggingSwitch))
{
emitLogging = emitLoggingSwitch.Equals("true", StringComparison.OrdinalIgnoreCase);
}
// add the source with or without logging...
}
public void Initialize(GeneratorInitializationContext context)
{
}
}
Consume MSBuild properties and metadata
使用MSBuild属性和元数据
Implementation status: Available from VS 16.7 preview3.
User Scenarios:
- 作为生成器作者,我希望根据项目文件中包含的值做出决策
- 作为生成器的用户,我希望能够自定义生成的代码并覆盖默认值。
Solution: MSBuild将自动将指定的属性和元数据转换为生成器可以读取的全局分析器配置。生成器作者通过向CompilerVisibleProperty
和CompilerVisibleItemMetadata
项组添加项来指定要使其可用的属性和元数据。在将生成器打包为NuGet包时,可以通过道具或目标文件添加这些内容。
例如,假设有一个生成器基于其他文件创建源,并希望允许用户通过项目文件启用或禁用日志记录。作者将在他们的道具文件中指定他们想要使指定的MSBuild属性对编译器可见:
<ItemGroup>
<CompilerVisibleProperty Include="MyGenerator_EnableLogging" />
</ItemGroup>
然后,MyGenerator_EnableLogging
属性的值将在构建前发送到生成的分析器配置文件,名称为build_property.MyGenerator_EnableLogging
。然后,生成器可以通过GeneratorExecutionContext
的AnalyzerConfigOptions
属性读取此属性:
context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.MyGenerator_EnableLogging", out var emitLoggingSwitch);
因此,用户可以通过在其项目文件中设置属性来启用或禁用日志记录。
现在,考虑生成器作者希望有选择地允许在每个附加文件的基础上选择加入/退出登录。作者可以通过添加到CompilerVisibleItemMetadata
项组来请求MSBuild发出指定文件的元数据的值。作者既指定了他们想要从中读取元数据的MSBuild itemType(在本例中是AdditionalFiles
),也指定了他们想要为它们检索的元数据的名称。
<ItemGroup>
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="MyGenerator_EnableLogging" />
</ItemGroup>
对于编译中的每个附加文件,MyGenerator_EnableLogging
的此值将被发送到生成的分析器配置文件,项目名称为build_metadata.AdditionalFiles.MyGenerator_EnableLogging
。生成器可以在每个附加文件的上下文中读取此值:
foreach (var file in context.AdditionalFiles)
{
context.AnalyzerConfigOptions.GetOptions(file).TryGetValue("build_metadata.AdditionalFiles.MyGenerator_EnableLogging", out var perFileLoggingSwitch);
}
在用户项目文件中,用户现在可以注释各个附加文件,以说明他们是否要启用日志记录:
<ItemGroup>
<AdditionalFiles Include="file1.txt" /> <!-- logging will be controlled by default, or global value -->
<AdditionalFiles Include="file2.txt" MyGenerator_EnableLogging="true" /> <!-- always enable logging for this file -->
<AdditionalFiles Include="file3.txt" MyGenerator_EnableLogging="false" /> <!-- never enable logging for this file -->
</ItemGroup>
Full Example:
MyGenerator.props:
<Project>
<ItemGroup>
<CompilerVisibleProperty Include="MyGenerator_EnableLogging" />
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="MyGenerator_EnableLogging" />
</ItemGroup>
</Project>
MyGenerator.csproj:
<Project>
<PropertyGroup>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild> <!-- Generates a package at build -->
<IncludeBuildOutput>false</IncludeBuildOutput> <!-- Do not include the generator as a lib dependency -->
</PropertyGroup>
<ItemGroup>
<!-- Package the generator in the analyzer directory of the nuget package -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<!-- Package the props file -->
<None Include="MyGenerator.props" Pack="true" PackagePath="build" Visible="false" />
</ItemGroup>
</Project>
MyGenerator.cs:
[Generator]
public class MyGenerator : ISourceGenerator
{
public void Execute(GeneratorExecutionContext context)
{
// global logging from project file
bool emitLoggingGlobal = false;
if(context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.MyGenerator_EnableLogging", out var emitLoggingSwitch))
{
emitLoggingGlobal = emitLoggingSwitch.Equals("true", StringComparison.OrdinalIgnoreCase);
}
foreach (var file in context.AdditionalFiles)
{
// allow the user to override the global logging on a per-file basis
bool emitLogging = emitLoggingGlobal;
if (context.AnalyzerConfigOptions.GetOptions(file).TryGetValue("build_metadata.AdditionalFiles.MyGenerator_EnableLogging", out var perFileLoggingSwitch))
{
emitLogging = perFileLoggingSwitch.Equals("true", StringComparison.OrdinalIgnoreCase);
}
// add the source with or without logging...
}
}
public void Initialize(GeneratorInitializationContext context)
{
}
}
Unit Testing of Generators
User scenario: 作为生成器作者,我希望能够对生成器进行单元测试,以简化开发并确保正确性。
Solution A:
The recommended approach is to use Microsoft.CodeAnalysis.Testing packages:
Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.MSTest
Microsoft.CodeAnalysis.VisualBasic.SourceGenerators.Testing.MSTest
Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.NUnit
Microsoft.CodeAnalysis.VisualBasic.SourceGenerators.Testing.NUnit
Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.XUnit
Microsoft.CodeAnalysis.VisualBasic.SourceGenerators.Testing.XUnit
这与分析器和代码修复测试的工作方式相同。您可以添加如下所示的类:
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing.Verifiers;
public static class CSharpSourceGeneratorVerifier<TSourceGenerator>
where TSourceGenerator : ISourceGenerator, new()
{
public class Test : CSharpSourceGeneratorTest<TSourceGenerator, XUnitVerifier>
{
public Test()
{
}
protected override CompilationOptions CreateCompilationOptions()
{
var compilationOptions = base.CreateCompilationOptions();
return compilationOptions.WithSpecificDiagnosticOptions(
compilationOptions.SpecificDiagnosticOptions.SetItems(GetNullableWarningsFromCompiler()));
}
public LanguageVersion LanguageVersion { get; set; } = LanguageVersion.Default;
private static ImmutableDictionary<string, ReportDiagnostic> GetNullableWarningsFromCompiler()
{
string[] args = { "/warnaserror:nullable" };
var commandLineArguments = CSharpCommandLineParser.Default.Parse(args, baseDirectory: Environment.CurrentDirectory, sdkDirectory: Environment.CurrentDirectory);
var nullableWarnings = commandLineArguments.CompilationOptions.SpecificDiagnosticOptions;
return nullableWarnings;
}
protected override ParseOptions CreateParseOptions()
{
return ((CSharpParseOptions)base.CreateParseOptions()).WithLanguageVersion(LanguageVersion);
}
}
}
Then, in your test file:
using VerifyCS = CSharpSourceGeneratorVerifier<YourGenerator>;
And use the following in your test method:
var code = "initial code"
var generated = "expected generated code";
await new VerifyCS.Test
{
TestState =
{
Sources = { code },
GeneratedSources =
{
(typeof(YourGenerator), "GeneratedFileName", SourceText.From(generated, Encoding.UTF8, SourceHashAlgorithm.Sha256)),
},
},
}.RunAsync();
Solution B:
另一种不使用测试库的方法是,用户可以直接在单元测试中托管GeneratorDriver
,从而使代码的生成器部分对单元测试相对简单。用户需要为生成器提供要操作的编译,然后可以探测生成的编译或驱动程序的GeneratorDriverRunResult
,以查看生成器添加的各个项。
从添加单个源文件的基本生成器开始:
[Generator]
public class CustomGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context) {}
public void Execute(GeneratorExecutionContext context)
{
context.AddSource("myGeneratedFile.cs", SourceText.From(@"
namespace GeneratedNamespace
{
public class GeneratedClass
{
public static void GeneratedMethod()
{
// generated code
}
}
}", Encoding.UTF8));
}
}
As a user, we can host it in a unit test like so:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
namespace GeneratorTests.Tests
{
[TestClass]
public class GeneratorTests
{
[TestMethod]
public void SimpleGeneratorTest()
{
// Create the 'input' compilation that the generator will act on
Compilation inputCompilation = CreateCompilation(@"
namespace MyCode
{
public class Program
{
public static void Main(string[] args)
{
}
}
}
");
// directly create an instance of the generator
// (Note: in the compiler this is loaded from an assembly, and created via reflection at runtime)
CustomGenerator generator = new CustomGenerator();
// Create the driver that will control the generation, passing in our generator
GeneratorDriver driver = CSharpGeneratorDriver.Create(generator);
// Run the generation pass
// (Note: the generator driver itself is immutable, and all calls return an updated version of the driver that you should use for subsequent calls)
driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out var outputCompilation, out var diagnostics);
// We can now assert things about the resulting compilation:
Debug.Assert(diagnostics.IsEmpty); // there were no diagnostics created by the generators
Debug.Assert(outputCompilation.SyntaxTrees.Count() == 2); // we have two syntax trees, the original 'user' provided one, and the one added by the generator
Debug.Assert(outputCompilation.GetDiagnostics().IsEmpty); // verify the compilation with the added source has no diagnostics
// Or we can look at the results directly:
GeneratorDriverRunResult runResult = driver.GetRunResult();
// The runResult contains the combined results of all generators passed to the driver
Debug.Assert(runResult.GeneratedTrees.Length == 1);
Debug.Assert(runResult.Diagnostics.IsEmpty);
// Or you can access the individual results on a by-generator basis
GeneratorRunResult generatorResult = runResult.Results[0];
Debug.Assert(generatorResult.Generator == generator);
Debug.Assert(generatorResult.Diagnostics.IsEmpty);
Debug.Assert(generatorResult.GeneratedSources.Length == 1);
Debug.Assert(generatorResult.Exception is null);
}
private static Compilation CreateCompilation(string source)
=> CSharpCompilation.Create("compilation",
new[] { CSharpSyntaxTree.ParseText(source) },
new[] { MetadataReference.CreateFromFile(typeof(Binder).GetTypeInfo().Assembly.Location) },
new CSharpCompilationOptions(OutputKind.ConsoleApplication));
}
}
Note: 上面的示例使用了MSTest,但是测试的内容很容易适应其他框架,比如XUnit。
Participate in the IDE experience
参与IDE体验
Implementation Status: Not Implemented.
User scenario: 作为生成器作者,我希望能够在用户编辑文件时交互地重新生成代码。
Solution: 我们预计会有一组可供选择的交互式回调,可以实现这些回调以支持逐渐复杂的生成策略。预计将会有一种机制,用于提供符号映射以点亮特征,例如“查找所有引用”(Find All Reference)。
[Generator]
public class InteractiveGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// Register for additional file callbacks
context.RegisterForAdditionalFileChanges(OnAdditionalFilesChanged);
}
public void Execute(GeneratorExecutionContext context)
{
// generators must always support a total generation pass
}
public void OnAdditionalFilesChanged(AdditionalFilesChangedContext context)
{
// determine which file changed, and if it affects this generator
// regenerate only the parts that are affected by this change.
}
}
Note: 在这些接口可用之前,生成器作者不应该尝试通过缓存到磁盘和自定义最新检查来模仿“增量”。编译器目前没有为生成器提供可靠的方法来检测它是否适合使用以前的运行,任何这样做的尝试都可能导致很难为使用者诊断错误。生成器的作者应该总是假设这是一个“完整”的一代,这是第一次发生。
Serialization
User Scenario
序列化通常使用动态分析(dynamic analysis)来实现,即序列化程序通常使用反射来检查给定类型的运行时状态并生成序列化逻辑。这可能是昂贵和易碎的。如果编译时类型和运行时类型相似,那么将大部分成本转移到编译时而不是运行时可能会很有用。
源生成器提供了一种实现此目的的方法。由于源代码生成器可以像分析器一样通过NuGet交付,我们预计这将是一个源代码生成器库的用例,而不是每个人都构建自己的库。
Solution
首先,生成器需要某种方法来发现哪些类型是可序列化的。一个指示符可以是属性,例如
[GeneratorSerializable]
partial class MyRecord
{
public string Item1 { get; }
public int Item2 { get; }
}
此属性还可用于在充分设计该功能的全部范围后参与IDE体验。在这种情况下,编译器将通知生成器每个标记了给定属性的类型,而不是生成器查找标记了给定属性的每个类型。现在,我们假设类型已提供给我们。
第一个任务是决定我们希望序列化返回什么。假设我们执行一个简单的JSON序列化,该序列化生成如下所示的字符串
{
"Item1": "abc",
"Item2": 11,
}
为此,我们可以向记录类型添加一个Serialize
方法,如下所示:
public string Serialize()
{
var sb = new StringBuilder();
sb.AppendLine("{");
int indent = 8;
// Body
addWithIndent($"\"Item1\": \"{this.Item1.ToString()}\",");
addWithIndent($"\"Item2\": {this.Item2.ToString()},");
sb.AppendLine("}");
return sb.ToString();
void addWithIndent(string s)
{
sb.Append(' ', indent);
sb.AppendLine(s);
}
}
显然,这被大大简化了—此示例仅正确处理string
和int
类型,在json输出中添加尾随逗号,并且没有错误恢复功能,但它应该用于演示源代码生成器可以添加到编译中的代码类型。
我们的下一个任务是设计一个生成器来生成上面的代码,因为上面的代码本身是根据类中的实际属性在// Body
部分中定制的。换句话说,我们需要生成将生成JSON格式的代码。这是一台generator-generator。
让我们从一个基本模板开始。我们要添加一个完整的源代码生成器,因此需要使用名为Serialize
的公共方法和一个写入属性的填充区域生成一个与输入类同名的类。
string template = @"
using System.Text;
partial class {0}
{{
public string Serialize()
{{
var sb = new StringBuilder();
sb.AppendLine(""{{"");
int indent = 8;
// Body
{1}
sb.AppendLine(""}}"");
return sb.ToString();
void addWithIndent(string s)
{{
sb.Append(' ', indent);
sb.AppendLine(s);
}}
}}
}}";
现在我们已经了解了代码的一般结构,我们需要检查输入类型并找到要填写的所有正确信息。在我们的示例中,这些信息都在C#语法树中可用。假设我们获得了一个ClassDeclarationSynTax
,该语法被确认附加了一个生成属性。然后,我们可以获取类的名称及其属性的名称,如下所示:
private static string Generate(ClassDeclarationSyntax c)
{
var className = c.Identifier.ToString();
var propertyNames = new List<string>();
foreach (var member in c.Members)
{
if (member is PropertyDeclarationSyntax p)
{
propertyNames.Add(p.Identifier.ToString());
}
}
}
这真的就是我们所需要的。如果属性的序列化值是它们的字符串值,则生成的代码只需要对它们调用ToString()
。剩下的唯一问题是在文件顶部放置什么using
。由于我们的模板使用字符串构建器,因此需要System.Text
,但所有其他类型似乎都是基本类型,所以这就是我们需要的全部内容。把所有这些放在一起:
private static string Generate(ClassDeclarationSyntax c)
{
var sb = new StringBuilder();
int indent = 8;
foreach (var member in c.Members)
{
if (member is PropertyDeclarationSyntax p)
{
var name = p.Identifier.ToString();
appendWithIndent($"addWithIndent($\"\\\"{name}\\\": ");
if (p.Type.ToString() != "int")
{
sb.Append("\\\"");
}
sb.Append($"{{this.{name}.ToString()}}");
if (p.Type.ToString() != "int")
{
sb.Append("\\\"");
}
sb.AppendLine(",\");");
}
}
return $@"
using System.Text;
partial class {c.Identifier.ToString()}
{{
public string Serialize()
{{
var sb = new StringBuilder();
sb.AppendLine(""{{"");
int indent = 8;
// Body
{sb.ToString()}
sb.AppendLine(""}}"");
return sb.ToString();
void addWithIndent(string s)
{{
sb.Append(' ', indent);
sb.AppendLine(s);
}}
}}
}}";
void appendWithIndent(string s)
{
sb.Append(' ', indent);
sb.Append(s);
}
}
这与其他序列化示例紧密地联系在一起。通过在编译的语法树中查找所有合适的类声明,并将它们传递给上面的Generate方法,我们可以为选择使用生成的序列化的每种类型构建新的分部类。与其他技术不同的是,这种序列化机制完全在编译时发生,并且可以完全专用于User类中编写的内容。
Auto interface implementation
Breaking Changes:
Implementation status: Implemented in Visual Studio 16.8 preview3 / roslyn version 3.8.0-3.final onwards
在预览版和发行版之间,引入了以下突破性更改:
Rename **SourceGeneratorContext**
to **GeneratorExecutionContext**
Rename **IntializationContext**
to **GeneratorInitializationContext**
这会影响用户编写的生成器,因为这意味着基界面已更改为:
public interface ISourceGenerator
{
void Initialize(GeneratorInitializationContext context);
void Execute(GeneratorExecutionContext context);
}
尝试使用针对更高版本Roslyn的预览API的生成器时,用户将看到类似以下内容的异常:
CSC : warning CS8032: An instance of analyzer Generator.HelloWorldGenerator cannot be created from Generator.dll : Method 'Initialize' in type 'Generator.HelloWorldGenerator' from assembly 'Generator, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' does not have an implementation. [Consumer.csproj]
用户需要执行的操作是重命名Initialize和Execute方法的参数类型以匹配。
Rename **RunFullGeneration**
to **RunGeneratorsAndUpdateCompilation**
Add **Create()**
static methods to **CSharpGeneratorDriver**
and obsolete the constructor.
这会影响任何使用CSharpGeneratorDriver
编写单元测试的生成器作者。要创建生成器驱动程序的新实例,用户不应该再调用new
,而应该使用CSharpGeneratorDriver.Create()
重载之一。用户不应再使用RunFullGeneration
方法,而应使用相同的参数调用RunGeneratorsAndUpdateCompilation
。
Open Issues
本节跟踪其他杂项待办事项:
Framework targets: 可能要提到我们是否对生成器有框架要求,例如,它们必须以netStandard2.0或类似的为目标。
Conventions: (请参阅上面约定部分中的TODO)。我们向用户建议哪些标准约定?
Partial methods: 我们是否应该提供包含分部方法的场景?原因:
- 对姓名的控制。开发人员可以控制成员的名称
- 生成是可选的/取决于其他状态。根据其他信息,生成器可能会决定不需要该方法。
Feature detection: 展示如何创建依赖于特定目标框架特性的生成器,而不依赖于TargetFramework属性。