Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
vermeir_nico_introducing_net_6_getting_started_with_blazor_m.pdf
Скачиваний:
19
Добавлен:
26.06.2023
Размер:
11.64 Mб
Скачать

Chapter 10 .NET Compiler Platform

Source Generators

A recent addition to the Compiler Platform is source generators. Source generators run during the compilation of your code. They can generate extra code files based on analysis of your code and include them in the compilation.

Source generators are written in C#; they can retrieve an object that is a representation of the code you have written. That object can be analyzed and used to generate extra source files based on the syntax and semantic models that are in the compilation object. Figure 10-9 shows where in the compilation pipeline the source generators live.

Figure 10-9.  Compiler pipeline (image by Microsoft)

Source generators can be used to prevent the use of reflection. Instead of generating runtime classes, it might be possible to generate extra classes at compile time, of course depending on your use case. Being able to generate extra classes at compile time instead of runtime almost always means a performance increase. It is important to know, and remember, that source generators can only generate and inject extra code; they cannot change the code that was written by the developer.

Writing a Source Generator

Let us look at an example. For the example, we are going to write a source generator that takes any class that is decorated with a certain attribute and generate a record-type DTO from that class; DTOs are explained in more detail in the previous chapter. We will keep it quite simple for this demo generator, so do not worry about violating any DTO best practices in the generated code.

285

Chapter 10 .NET Compiler Platform

Source generators work with .NET 6 projects, but they need to be defined in a .NET Standard 2.0 library at the time of writing. After creating a .NET Standard 2.0 class library, we add a class that implements ISourceGenerator. To get the ISourceGenerator, we first need to install the Microsoft.CodeAnalysis NuGet package. Listing 10-4 shows the interface with its members.

Listing 10-4.  ISourceGenerator interface

public interface ISourceGenerator

{

void Initialize(GeneratorInitializationContext context); void Execute(GeneratorExecutionContext context);

}

ISourceGenerator consists of two methods. The Initialize method sets up the generator, while the Execute method does the actual generating of code.

For testing our generator, we will create a .NET 6 console application. After creating the project, we start by defining a very simple attribute. Listing 10-5 shows the attribute declaration.

Listing 10-5.  Defining the attribute to filter on

internal class GenerateDtoAttribute : Attribute

{

}

We only need this attribute to do filtering at the time of generating, so no extra implementation is needed on the attribute class. Finally we add some classes and decorate them with the GenerateDto attribute, as shown in Listing 10-6.

Listing 10-6.  Example of a decorated class

[GenerateDto] public class Product

{

public string

Name {

get; set;

}

public

string

Description { get; set; }

public

double

Price{

get; set;

}

}

286

Chapter 10 .NET Compiler Platform

Next we turn to the .NET Standard 2.0 project to implement our source generator. First thing we need to do is identify what classes are decorated with the GenerateDto attribute. To do this, we need to traverse the syntax tree and inspect the class nodes; this is done by an object called a Syntax Receiver. Syntax Receivers are objects that visit nodes and allow us to inspect them and save them to a collection that can be used for generating code. The Syntax Receivers are configured in GeneratorExecutonContext’s SyntaxReceiver property. The GeneratorExecutonContext is an object that gets passed into the Initialization of a source generator, which we will get to in a moment. Every time the source generator runs, it creates exactly one instance of its Syntax Receiver, meaning that every inspected node is done by the same receiver instance. Listing 10-7 demonstrates a Syntax Receiver that filters out class nodes that are decorated with our

GenerateDto attribute.

Listing 10-7.  SyntaxReceiver

internal class SyntaxReceiver : ISyntaxReceiver

{

public List<ClassDeclarationSyntax> DtoTypes { get; } = new List<ClassDeclarationSyntax>();

public void OnVisitSyntaxNode(SyntaxNode syntaxNode)

{

if (!(syntaxNode is ClassDeclarationSyntax classDeclaration) || !classDeclaration.AttributeLists.Any())

{

return;

}

bool requiresGeneration = classDeclaration.AttributeLists. Count > 0 &&

classDeclaration.AttributeLists

.SelectMany(_ => _.Attributes.Where(a => (a.Name as IdentifierNameSyntax).Identifier.Text == "GenerateDto"))

.Any();

287

Chapter 10 .NET Compiler Platform

if (requiresGeneration)

{

DtoTypes.Add(classDeclaration);

}

}

}

A Syntax Receiver is a class that implements the ISyntaxReceiver interface. The interface contains one member, an OnVisitSyntaxNode method. This method will be executed for every node in the syntax tree build by the Compiler Platform SDK. In this implementation, we inspect every node to see if it is of type

ClassDeclarationSyntax. There are declaration syntax types for every type of node we can expect, including ClassDeclarationSyntax, InterfaceDeclarationSyntax,

PropertyDeclarationSyntax, and so on. Once we have a ClassDeclarationSyntax that contains attributes, we use LINQ to check if the class contains our custom attribute. Once we have the IdentifierNameSyntax, we can verify if it has the name of the attribute we are filtering on, in this case GenerateDto. At this point, we have successfully detected a class that was decorated with the GenerateDto attribute, but we are not generating code yet; we are just traversing the syntax tree; that is why we save the found class nodes in an immutable property. The syntax receiver is single instance for every generator run anyway, so we can safely use properties to bring data from the receiver to the generator.

Let’s have a look at implementing the actual generator. We’ll start with the Initialize method that is part of the ISourceGenerator contract.

Listing 10-8.  Initializing a source generator

[Generator]

public class MySourceGenerator : ISourceGenerator

{

public void Initialize(GeneratorInitializationContext context)

{

context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());

}

288

Chapter 10 .NET Compiler Platform

In a source generator, a GeneratorInitializationContext object is passed into the Initialize method and a GeneratorExecutionContext is passed into the Execute method; this allows the Initialize method to, well, initialize the source generator. In this example, we use it to register our SyntaxReceiver into the generator pipeline. From this point on, whenever the generator runs, it will pass every syntax node through the receiver. The Execute method runs as part of the compilation pipeline whenever a source generator is installed into a project.

Listing 10-9.  Checking for the receiver

public void Execute(GeneratorExecutionContext context)

{

if (!(context.SyntaxReceiver is SyntaxReceiver receiver))

{

return;

}

Our Execute method only works when the context contains the correct receiver. A quick typecheck makes sure everything is in order.

Listing 10-10.  Grabbing properties and using statements

foreach (ClassDeclarationSyntax classDeclaration in receiver. DtoTypes)

{

var properties = classDeclaration.DescendantNodes().OfType<Property DeclarationSyntax>();

var usings = classDeclaration.DescendantNodes().OfType<UsingDirective Syntax>();

Next we loop over the list of class declarations we have captured in the receiver. By the time we get to this point in the code, the receiver will have done its work and the list will be filled with class declarations of classes that are decorated with the

GenerateDto attribute. From every class declaration, we grab the properties, by looking for nodes of type PropertyDeclarationSyntax and the using directives by looking for UsingDirectiveSyntax. We need these because if we are going to generate records for every class, we need to know the properties so we can copy them and the using directives so that all the types can be resolved in their namespaces.

289

Chapter 10 .NET Compiler Platform

Listing 10-11.  Generating the using directives

var sourceBuilder = new StringBuilder();

foreach (UsingDirectiveSyntax usingDirective in usings)

{

sourceBuilder.AppendLine(usingDirective.FullSpan.ToString());

}

In Listing 10-11, we finally start generating code. We are using a StringBuilder to write out the entire code file before inserting it into the code base. First things to generate are the using directives. We already have a collection containing them, so we simply loop over the directives and call the AppendLine method to write it out. We use

the FullSpan property on the UsingDirectiveSyntax; that property contains the entire instruction the node was parsed from, for example, using System.Linq.

Listing 10-12.  Generating namespace and class declarations

var className = classDeclaration.Identifier.ValueText; var namespaceName = (classDeclaration.Parent as NamespaceDeclarationSyntax).Name.ToString();

sourceBuilder.AppendLine($"namespace {namespaceName}.Dto"); sourceBuilder.AppendLine("{");

sourceBuilder.Append($"public record {className} (");

The next things we need are namespace and record declarations. We can get those from the class declaration we are currently processing. The class name can be found in the Identifier property of the ClassDeclarationSyntax object. In this example, we are assuming that there are no nested classes, so the parent object of a class should always be a namespace object. By casting the parent object as a NamespaceDeclarationSyntax object, we can get to the Name property. Using the StringBuilder from Listing 10-11 and some string interpolation, we add the needed code. Be careful with the brackets, try to envision what the generated code will look like, and make sure that all necessary brackets are there and properly closed when needed. We are building code as a simple string, so no intellisense here.

290

Chapter 10 .NET Compiler Platform

Listing 10-13.  Generating parameters and injecting the code

foreach (PropertyDeclarationSyntax property in properties)

{

string propertyType = property.Type.ToString(); string propertyName = property.Identifier.ValueText;

sourceBuilder.Append($"{propertyType} {propertyName}, ");

}

//remove the final ', ' sourceBuilder.Remove(sourceBuilder.Length - 2, 2);

sourceBuilder.Append(");");

sourceBuilder.AppendLine("}");

context.AddSource(classDeclaration.Identifier.ValueText, SourceText. From(sourceBuilder.ToString(), Encoding.UTF8));

Finally we use the list of properties we have from the class declaration to generate the record parameters. We can grab the datatype from the property’s Type property and the name from the Identifier property. We use the StringBuilder’s Append method to make sure that all parameters are appended on one line instead of adding a line break between each one. The parameters are separated with a comma, and the final comma is removed. Finally we close the brackets and our code is finished. We can use the AddSource method on the GeneratorExecutionContext object to inject the source into the codebase right before the code gets compiled. Our generated code is now part of the user code and will be treated as such by the compiler.

The final step in the process is linking the source generator to the project where we want to use it. Source generators are added as analyzers into the csproj file.

Listing 10-14.  Adding a source generator to a project

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>

<OutputType>Exe</OutputType>

<TargetFramework>net6.0</TargetFramework>

<ImplicitUsings>enable</ImplicitUsings>

</PropertyGroup>

291

Chapter 10 .NET Compiler Platform

<ItemGroup>

<ProjectReference Include="..\SourceGeneratorLibrary\ SourceGeneratorLibrary.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

</ItemGroup>

</Project>

The ItemGroup node in Listing 10-14 shows how to add a source generator. From this moment on, the source generator will run every time the project gets build. We can see if it works by loading the generated assembly in a decompiler like ILSpy. Upon inspection, we immediately see the Dto namespace appearing.

Figure 10-10.  Dto namespace in ILSpy

When we inspect the namespace, we’ll see generated records for every class that was decorated with the GenerateDto attribute.

Figure 10-11.  Generated record

Since we have these objects available now, we can also instantiate them from code.

Listing 10-15.  Using the generated DTO objects

var product = new Product("Introducing .NET 6", "Book by Apress about .NET 6", 50.0);

292