Dans la première partie de cette série de billet sur les Source Generators
nous avons défini ce qu'ils étaient et dans quel cas nous pourrions les utiliser, sans donner d'exemple de code. C'était volontaire, pour éviter d'avoir un article trop long. Dans cette seconde partie, nous allons donc en profiter pour explorer un peu plus les Source Generators
au travers d'exemples.
Il existe à ce jour deux API pour créer un Source Generator
. L'une d'elle, la plus répandue dans les exemples et les repo git, est l'implémentation de ISourceGenerator
, la seconde est une implémentation de IIncrementalGenerator
.
Pourquoi deux API pour faire la même chose, me direz-vous ? Et bien, la première citée est aujourd'hui dépréciée et correspond à la V1 de l'api Source Generators
qui avait quelques soucis de performance que nous allons explorer au travers de cet article. La seconde API est donc une nouvelle façon de faire des Source Generators
venant pallier aux faiblesses de la première version, mais nous y reviendrons dans le prochain article.
Pour explorer ces deux API nous allons réaliser le même exemple, à savoir créer un Source Generator
qui génère un dto à partir d'un modèle. Exemple volontairement très simple pour découvrir les Source Generators
.
Projet Source Generator
Avant toute chose, nous allons créer un nouveau projet SourceGenerators
dans notre solution pour notre generator. Ce projet sera de type Class Library
et ciblera netstandard2.0
et référencera deux package pour pouvoir créer des Source Generators
, Microsoft.CodeAnalysis.CSharp
et Microsoft.CodeAnalysis.Analyzers
.
Nous allons aussi spécifier que nous souhaitons utiliser la dernière version de C# pour profiter des nouveautés du langage grâce à la propriété LangVersion
.
Ce qui nous donnera le .csproj
suivant:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.6.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
</Project>
Il nous reste ensuite à créer une application console SourceGenerators.Sample
qui viendra référencer notre projet Source Generators
:
<ItemGroup>
<ProjectReference Include="..\SourceGenerators\SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
</ItemGroup>
OutputItemType="Analyzer"
indique que le projet référencé (SourceGenerators) doit être traité comme un analyseur, lui permettant alors d'analyser le code source pendant la compilation pour générer du code et l'ajouter à la solution. Sans cette option, il ne sera pas possible pour le projet SourceGenerators d'analyser votre solution à la compilation.
ReferenceOutputAssembly="false"
indique que l'assembly SourceGenerators ne doit pas être référencé par le projet SourceGenerators.Sample. Cela implique que le projet SourceGenerators.Sample ne peut pas utiliser directement les types ou les membres définis dans l'assembly SourceGenerators. Il est préférable de laisser la valeur de cette propriété à false
pour ne pas risquer d'utiliser le code du Generator dans votre projet.
La bonne pratique reste d'utiliser un projet Source Generator uniquement pour générer du code à la compilation et non à l'exécution.
ISourceGenerator
Maintenant que nous avons créer notre projet SourceGenerators, nous allons pouvoir créer notre premier Generator. Pour déclarer un générator, il suffit de créer une classe qui implémente l'interface ISourceGenerator
et que l'on annote avec l'attribut Generator
.
Cette interface définit deux méthodes : Initialize
et Execute
. La méthode Initialize
est appelée au début de la compilation pour configurer le pipeline de génération, tandis que la méthode Execute
est appelée une fois que le compilateur a analysé le code source pour générer du code supplémentaire.
[Generator]
public class SimpleDtoGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// Initialization code
}
public void Execute(GeneratorExecutionContext context)
{
// Generation code
}
}
Initialize
Initialize
est appelée une seule fois et permet de configurer le pipeline de génération. Elle prend en argument une instance de GeneratorInitializationContext
qui permet au generator de créer des callbacks pour les différentes phases de la compilation, comme la génération de l'arbre syntaxique, la génération du modèle sémantique ou la génération de code post-compilation.
Dans le cadre de notre exemple du DtoGenerator, nous allons utiliser la méthode Initialize
pour générer l'attribut GenerateDtoAttribute
. Pourquoi générer l'attribut avec le generator plutôt que créer l'attribut directement dans la librairie ? Souvenez-vous du chapitre précédent, la propriété ReferenceOutputAssembly="false"
ne nous permet pas de référencer notre projet SourceGenerators. Ce qui nous laisse deux possibilités pour proposer cet attribut à nos utilisateurs :
Demander à nos utilisateurs de créer eux-mêmes les attributs dont ils ont besoin pour utiliser notre generator
Proposer une librairie annexe avec l'ensemble des dépendances nécessaires
Ici, la solution choisie : générer ces attributs directement à la compilation.
Finalement, notre méthode Initialize
ressemblerait à ça:
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForPostInitialization(i =>i.AddSource("Attributes/GenerateDtoAttribute", SourceText.From("""
// <auto-generated/>
using System;
namespace Generators;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
public class GenerateDtoAttribute : Attribute
{
}
""", Encoding.UTF8)));
}
Grâce à la méthode RegisterForPostInitialization
nous indiquons vouloir nous inscrire pour être notifié lors que l'initialisation de tous les générators sera terminée pour exécuter l'action que nous avons passée en paramètre. Cette action, exploite l'API AddSource
permettant d'ajouter du code source à notre solution en lui passant le nom du fichier que l'on souhaite créer, ainsi que le code de la classe que nous souhaitons générer.
À cette étape, nous pouvons déjà lancer la compilation de notre solution et constater avec stupeur, qu'aucun fichier n'a été ajouté à notre solution. Du moins en apparence. Pour trouver les fichiers générés, il faut ouvrir votre projet SourceGenerators.Sample et ouvrir l'arborescence de dépendances, sous le dossier .NET 7.0
vous trouverez un dossier Source Generators
où l'ensemble de vos fichiers générés seront disponibles.
On peut donc dès maintenant décorer nos classes dans notre projet SourceGenerators.Sample avec cet attribut auto-généré.
using Generators;
namespace SourceGenerators.Sample;
[GenerateDto]
public record Student(string Name);
Execute
Dans la méthode Execute
, vous pouvez accéder à l'arbre syntaxique et le modèle sémantique de votre code, ce qui vous permet d'inspecter le code source existant. Vous pouvez également ajouter du code source supplémentaire à la compilation en appelant context.AddSource
comme nous avons pu le faire dans la méthode Initialize
.
public void Execute(GeneratorExecutionContext context)
{
// Parcourir toutes les classes marquées avec [GenerateDto]
}
Pour commencer, il nous faut filtrer les classes avec l'attribut GenerateDto
. Nous allons donc récupérer dans l'arbre syntaxique de notre code l'ensemble des noeuds qui correspondent à un record
ou class
et portant l'attribut [GenerateDto]
.
Pour cibler les noeuds de types classe ou record il faut sélectionner les noeuds de type BaseTypeDeclarationSyntax
.
// Recherche les classes avec l'attribut GenerateDto
var classesWithAttributeGenerateDto = context.Compilation.SyntaxTrees
.SelectMany(syntaxTree => syntaxTree.GetRoot().DescendantNodes())
.OfType<BaseTypeDeclarationSyntax>()
.Where(
typeDeclarationSyntax => typeDeclarationSyntax.AttributeLists.Any(
attributeListSyntax => attributeListSyntax.Attributes.Any(
attributeSyntax => attributeSyntax.Name.ToString() == "GenerateDto")));
Une fois que nous avons filtré les classes qui nous intéresse, nous allons pouvoir itérer sur ces classes pour créer les Dto correspondant. Commençons simplement en récupérant les using de la classe d'origine.
Ce qui nous donne le code suivant :
foreach (var classWithAttributeGenerateDto in classesWithAttributeGenerateDto)
{
// Récupération des using
var usingDirectives = classWithAttributeGenerateDto.SyntaxTree.GetRoot()
.DescendantNodesAndSelf()
.OfType<UsingDirectiveSyntax>()
.Select(usingDirective => usingDirective.ToFullString());
var sourceBuilder = new StringBuilder().AppendLine("// <auto-generated />");
foreach (var usingDirective in usingDirectives)
{
sourceBuilder.AppendLine(usingDirective);
}
context.AddSource($"Models/{dtoClassName}.g.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
}
La seconde étape sera ensuite d'ajouter le namespace de notre Dto. Nous allons ici reprendre volontairement le même namespace que la classe d'origine.
Pour cela, nous allons récupérer le modèle sémantique de notre code pour pouvoir récupérer notre classe et son namespace.
foreach (var classWithAttributeGenerateDto in classesWithAttributeGenerateDto)
{
// ...
// Récupération du namespace
var semanticModel = context.Compilation.GetSemanticModel(classWithAttributeGenerateDto.SyntaxTree);
var classSymbol = semanticModel.GetDeclaredSymbol(classWithAttributeGenerateDto);
var namespaceName = classSymbol?.ContainingNamespace.ToDisplayString() ?? "Generators";
sourceBuilder.AppendLine($@"#nullable enable
namespace {namespaceName};
");
// ..
}IS
Et enfin, dernière étape: nous allons créer notre record et récupérer l'ensemble des propriétés de notre classe pour les ajouter à notre Dto. Comme notre Generator est capable de gérer les classes et les records, il nous faut ajouter un switch
pour récupérer les propriétés.
foreach (var classWithAttributeGenerateDto in classesWithAttributeGenerateDto)
{
// ...
// Récupération de la classe et des propriétés
var className = classWithAttributeGenerateDto.Identifier.Text;
var dtoClassName = $"{className}Dto";
var properties = classWithAttributeGenerateDto switch
{
ClassDeclarationSyntax classDeclaration => classDeclaration.Members.OfType<PropertyDeclarationSyntax>()
.Select(p => (p.Identifier.Text, p.Type.ToString())).ToArray(),
RecordDeclarationSyntax recordDeclaration => recordDeclaration.ParameterList?.Parameters
.Select(p => (p.Identifier.Text, p.Type.ToString())).ToArray(),
_ => throw new InvalidOperationException("Unsupported type declaration.")
};
sourceBuilder.AppendLine($@"public record {dtoClassName}({string.Join(", ", properties.Select(p => $"{p.Item2} {p.Text}"))})
{{");
// ...
}
Ici, aucune complexité, en utilisant le constructeur
Il est possible d'enrichir ce Generator en ajoutant deux méthodes de mapping entre notre Dto et notre modèle. Ici, j'ai fait le choix d'utiliser les opérateurs explicit, ce qui nous donne le code suivant :
foreach (var classWithAttributeGenerateDto in classesWithAttributeGenerateDto)
{
// ...
// Génération du mapping
if(properties.Any())
{
sourceBuilder.AppendLine($" public static explicit operator {dtoClassName}({className} model)" +
$" => new({string.Join(", ", properties.Select(p => $"model.{p.Text}"))});");
sourceBuilder.Append($" public static explicit operator {className}({dtoClassName} dto)" +
$" => new({string.Join(", ", properties.Select(p => $"dto.{p.Text}"))});");
}
// ...
}
L'interface ISyntaxReceiver
Cette interface permet à un Source Generator
d'analyser la syntaxe du code lors de la phase d'initialisation de la génération du code. Elle permet par exemple d'analyser notre code pour trouver toutes les classes qui contiennent un certain attribut pour générer du code spécifique, par exemple. Cette analyse peut avoir un coût sur les grosses solutions encore une fois. Mais elle peut nous permettre d'optimiser quelque peu notre code.
Dans notre méthode Execute
nous analysons l'arbre syntaxique à chaque exécution de la méthode et donc à chaque compilation. L'idée est ici de créer une classe ISyntaxReceiver
qui viendra filtrer les classes qui portent l'attribut GenerateDto
lors de la phase d'initialisation et non plus à l'exécution.
Créons donc un SyntaxReceiver
qui viendra filtrer les classes portant l'attribut GenerateDto
public class GenerateDtoSyntaxReceiver : ISyntaxReceiver
{
public List<BaseTypeDeclarationSyntax> CandidateTypes { get; } = new();
public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
{
if (syntaxNode is BaseTypeDeclarationSyntax typeDeclarationSyntax
&& typeDeclarationSyntax.AttributeLists.Any(
attributeListSyntax => attributeListSyntax.Attributes.Any(
attributeSyntax => attributeSyntax.Name.ToString() == "GenerateDto")))
{
CandidateTypes.Add(typeDeclarationSyntax);
}
}
}
Maintenant, il nous faut mettre à jour la méthode Initialize
de notre Generator pour utiliser la méthode RegisterForSyntaxNotifications
pour y inscrire notre GenerateDtoSyntaxReceiver
ainsi que la méthode Execute
pour ne prendre en compte que les classes filtré par notre receiver.
public void Initialize(GeneratorInitializationContext context)
{
context.RegisterForSyntaxNotifications(() => new GenerateDtoSyntaxReceiver());
// ...
}
public void Execute(GeneratorExecutionContext context)
{
if (context.SyntaxReceiver is not DtoSyntaxReceiver receiver)
return;
var classesWithAttributeGenerateDto = receiver.CandidateTypes;
// ...
}
Performance
ISourceGenerator
nous permet donc de générer assez facilement du code source et de l'ajouter à notre solution. Toutefois, cette API se révèle avoir des impacts sur la performance de votre IDE lorsque vous travaillez sur des gros projets/solutions pour une raison simple :
L'ensemble du code source est analysé à chaque compilation. Même la plus petite modification entraine l'exécution de l'ensemble des Generators et la génération de code.
Pour vous donner une idée, voici une classe simple et son arbre syntaxique généreusement produit par notre ami GPT :
public class SimpleClass
{
public int SimpleProperty { get; set; }
}
ClassDeclaration("SimpleClass")
.WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword)))
.WithMembers(
SingletonList<MemberDeclarationSyntax>(
PropertyDeclaration(
PredefinedType(Token(SyntaxKind.IntKeyword)),
Identifier("SimpleProperty"))
.WithModifiers(TokenList(Token(SyntaxKind.PublicKeyword)))
.WithAccessorList(
AccessorList(
List(new AccessorDeclarationSyntax[]{
AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken)),
AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
.WithSemicolonToken(Token(SyntaxKind.SemicolonToken))})))));
Il faut également noter qu'une nouvelle instance de ISyntaxReceiver
est créé à chaque phase de génération. Ce qui implique la construction de l'arbre syntaxique des classes analysées à chaque génération. Plus on a de propriétés, plus l'arbre sera grand et complexe. On comprend donc rapidement, pourquoi à mesure que le projet grossit, la performance de notre IDE s'en voit impactée.
Mais dans le prochain article, je vous présenterai l'Api IIncrementalGenerator
qui vient corriger ces problèmes de performances.
Télécharger le code source
Si vous souhaitez télécharger l'exemple étudié au travers de cet article, je vous invite à suivre le lien suivant :