Source Generators par l'exemple : IIncrementalGenerator
Encore les
Source Generators
, il en a pas assez ?
Alors, oui, c'est un sujet assez vaste. Et à vrai dire, ma roadmap ne fait que s'allonger à mesure que je creuse le sujet. J'envisage encore d'aborder les tests sur notre code généré, le debugging et enfin faire un article détaillé sur mon futur mapper Source Generated
.
Dans la précédente partie, nous avons vu les limitations de l'Api ISourceGenerator
. Heureusement, une nouvelle api permettant de générer du code a été introduite avec .NET 6 : IIncrementalGenerator
.
API IIncrementalGenerator
L'API IIncrementalGenerator
a été introduite avec .NET 6, publié en novembre 2021. Elle vient apporter des améliorations aux Source Generators
introduit avec .NET 5 et les problèmes que nous avons remontés dans la partie précédente sur l'interface ISourceGenerator
.
Cette nouvelle API permet aux Source Generators
d'être plus efficace et plus performant lors de la génération de code en fonctionnant de manière incrémentielle, ce qui signifie que plutôt que d'analyser l'ensemble du code source à chaque compilation, ici il est possible de générer du code en fonction des modifications apportées à notre code. Cela ayant un impact direct et bénéfique sur le temps de compilation et les performances globale de notre projet et notre IDE.
Sans plus attendre, passons à la pratique en créant notre premier Generator
.
IIncrementalSourceGenerator
Première chose à faire: créer notre classe Generator
que nous allons sobrement appeller IncrementalDtoGenerator
. Il faut simplement préfixer notre classe de l'attribut Generator
et la faire implémenter l'interface IIncrementalGenerator
.
[Generator]
public class IncrementalDtoGenerator : Microsoft.CodeAnalysis.IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Code d'analyse et génération de code
}
}
Une seule méthode est disponible dans cette API : Initialize
.
Initialize
Cette méthode prend un seul paramètre : une instance de IncrementalGeneratorInitializationContext
. Ce contexte fournit des méthodes et des propriétés qui permettent de configurer le Generator.
Les méthodes qui nous intéressent sur ce contexte sont :
RegisterPostInitializationOutput
: Permettant d'ajouter des fichiers source après l'initialisation mais avant la génération de code.
Dans notre cas, nous allons l'utiliser pour ajouter la classe attribut que nous allons apposer sur les objets pour lesquels on souhaite générer des Dtos.
SyntaxProvider
: Permettant de filtrer et traiter les noeuds syntaxiques de notre code. Cela signifie que l'on peut filtrer par déclarations de classes ou de méthodes par exemple. Après ce filtrage, il est possible d'appliquer une fonction de traitement sur chacun de ces noeuds.
Dans notre cas, nous allons filtrer les objets de type class ou record et parcourir ces noeuds pour ne garder que ceux pour lesquels notre attribut
IncrementalDtoGenerator
est présent.
CompilationProvider
: Permettant d'obtenir un accès complet au contexte de compilation de notre projet. Ce qui permet d'obtenir des informations sur notre code en cours de compilation, y compris les métadonnées, les références, les options de compilations, etc. Il est souvent utilisé en combinaison avecSyntaxProvider
pour obtenir une vue complète de notre arbre syntaxique et du contexte de compilation global.RegisterSourceOutput
: Permettant d'enregistrer une action qui génère du code source.
Dans notre cas, nous allons l'utiliser en combinaisons avec les 2 précédentes méthodes pour générer les dtos et les ajouter à notre projet.
Cas pratique
Dans la pratique, voilà comment cela se présente. Comme pour les SourceGenerator
nous allons d'abord générer l'attribut que nous allons utiliser pour indiquer au Generator les classes qu'il devra analyser dans le but de générer du code. Ici nous utilisons la méthode RegisterPostInitializationOutput
présenté dans le paragraphe précédent.
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
namespace SourceGenerators.IIncrementalGenerator;
[Generator]
public class IncrementalDtoGenerator : Microsoft.CodeAnalysis.IIncrementalGenerator
{
private const string Namespace = "Generators";
private const string AttributeName = "IncrementalGenerateDtoAttribute";
private const string AttributeSourceCode =
$$"""
// <auto-generated/>
namespace {{Namespace}};
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
public class {{AttributeName}} : System.Attribute
{
}
""";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(ctx => ctx.AddSource("Attributes/IncrementalGenerateDtoAttribute.g.cs", SourceText.From(AttributeSourceCode, Encoding.UTF8)));
}
}
À ce stade, nous obtenons le même attribut que lors du précédent billet, nous n'entrerons donc pas davantage dans le détail. Nous allons maintenant configurer le pipeline de génération.
Filtrer pour mieux régner
Pour cela, nous allons utiliser le SyntaxProvider
qui nous permettra de filtrer les noeuds syntaxiques qui nous intéresse pour la génération de code. Décomposons cet extrait de code :
var provider = context.SyntaxProvider
.CreateSyntaxProvider(
(s, _) => s is RecordDeclarationSyntax or ClassDeclarationSyntax,
(ctx, _) => GetDeclarationForSourceGen(ctx))
.Where(t => t.AttributeFound)
.Select((t, _) => t.Node);
Nous utilisons la méthode CreateSyntaxProvider
qui retourne une instance de IncrementalValuesProvider<T>
. Cela permet d'analyser simplement le code mis à jour depuis la dernière compilation et non plus réévaluer l'ensemble de la solution comme se peut-être le cas avec ISourceGenerator
. Cela nous permet de bénéficier d'un gain de performance non négligeable.
(s, _) => s is RecordDeclarationSyntax or ClassDeclarationSyntax
Nous filtrons ensuite l'ensemble des modifications récupérées pour ne garder que les objets qui nous intéressent dans notre Generator
. Ici nous cherchons à identifier les noeuds de type class
ou record
.
Une fois ces noeuds filtré, nous pouvons exécuter une fonction sur ces noeuds. Dans notre cas, nous cherchons à vérifier si l'attribut IncrementalGenerateDtoAttribute
, que nous avons créer précédemment, est présent dans le code.
(ctx, _) => GetDeclarationForSourceGen(ctx)
Ici j'ai choisi de créer une méthode privée, pour plus de lisibilité. La méthode GetDeclarationForSourceGen
permet de retourner le noeud syntaxique courant et un booléen indiquant si oui ou non l'attribut IncrementalGenerateDtoAttribute
est présent dans la classe parcourue.
Nous récupérons la liste des attributs présents dans notre classe ou notre record pour vérifier la présence de notre attribut.
private static (SyntaxNode Node, bool AttributeFound) GetDeclarationForSourceGen(GeneratorSyntaxContext context)
{
var currentNode = context.Node;
var attributeLists = currentNode switch
{
ClassDeclarationSyntax classDeclaration => classDeclaration.AttributeLists,
RecordDeclarationSyntax recordDeclaration => recordDeclaration.AttributeLists,
_ => default
};
var attributeFound = attributeLists.Any(attributeList =>
attributeList.Attributes.Any(attributeSyntax =>
IsTargetAttribute(attributeSyntax, context.SemanticModel)));
return (currentNode, attributeFound);
}
private static bool IsTargetAttribute(AttributeSyntax attributeSyntax, SemanticModel semanticModel)
{
if (semanticModel.GetSymbolInfo(attributeSyntax).Symbol is IMethodSymbol attributeSymbol)
{
return attributeSymbol.ContainingType.ToDisplayString() == $"{Namespace}.{AttributeName}";
}
return false;
}
À la suite de cela, on ne garde que les noeuds pour lesquels la propriété AttributeFound
est à true
.
Where(t => t.AttributeFound)
Et enfin, on renvoit la liste des noeuds qui correspondent à nos classes candidates pour la génération de nos dtos.
Select((t, _) => t.Node)
En résumé: nous traitons donc le noeud courant en vérifiant son type, s'il n'est pas de type class ou record, nous renvoyons false
dans le champ AttributeFound
de notre tuple de retour, sinon nous passons dans la méthode IsTargetAttribute
qui vérifient dans la liste des attributs de la classe si notre attribut IncrementalGenerateDto
est présent. Lorsqu'il est présent, nous renvoyons true
et false
dans le cas contraire. Comme vous le voyez, il n'y a rien de bien compliqué dans ce code.
May the source be with you
À ce stade, nous avons créé notre attribut, nous l'avons apposés à nos classes candidates, puis nous avons parcourus notre code sources pour ne garder que les classes qui portent cet attribut. Reste à générer nos dtos !
context.RegisterSourceOutput(
context.CompilationProvider.Combine(provider.Collect()),
(ctx, t) => GenerateCode(ctx, t.Left, t.Right.OfType<TypeDeclarationSyntax>()));
Attend c'est tout ?
Et non, vous vous en doutez bien. Toute la magie se passe dans la méthode GenerateCode
qui prend en entrée, le contexte de notre source, celui de compilation et la liste des noeuds candidats de type TypeDeclarationSyntax
.
Dans un premier temps, nous allons générer le dto pour chacune de nos classes ou record de notre contexte.
private static void GenerateCode(SourceProductionContext context, Compilation compilation, IEnumerable<TypeDeclarationSyntax> declarations)
{
foreach (var declarationSyntax in declarations)
{
// On récupère le modèle sémantique pour pouvoir manipuler les méta données et le contenu de nos objets
var semanticModel = compilation.GetSemanticModel(declarationSyntax.SyntaxTree);
if (semanticModel.GetDeclaredSymbol(declarationSyntax) is not INamedTypeSymbol symbol) continue;
// On récupère le namespace, le nom du noeud courant et on créé le nom du futur DTO
var namespaceName = symbol.ContainingNamespace.ToDisplayString();
var domainName = declarationSyntax.Identifier.Text;
var dtoName = domainName + "Dto";
// On génère le code du dto, à ce stade il n'a ni propriétés, ni méthode de mapping
var source = new StringBuilder(
$$"""
// <auto-generated/>
#nullable enable
namespace {{namespaceName}};
public sealed record {{dtoName}};""");
// On ajoute enfin notre nouveau dto à notre code source
context.AddSource($"Models/{dtoName}.g.cs", SourceText.From(source.ToString(), Encoding.UTF8));
}
}
À ce stade, si vous exécutez le code vous n'obtenez qu'un record vide. Pas bien pratique. Ajoutons maintenant des propriétés à notre dto. Créons une méthode GenerateProperties
qui prend en entrée notre TypeDeclarationSyntax
. Du fait que notre Generator
manipule à la fois des classes et des records, nous devons ici utiliser un switch pour récupérer la liste des propriétés, ces deux objets n'ayant pas le même fonctionnement.
private static string GenerateProperties(TypeDeclarationSyntax declarationSyntax)
{
// On récupère l'ensemble des propriétés de notre type
IEnumerable<(string Name, string Type)> properties = declarationSyntax switch
{
// Dans le cas d'un record
RecordDeclarationSyntax record when record.ParameterList != null =>
record.ParameterList.Parameters.Select(p => (p.Identifier.Text, p.Type?.ToString() ?? "Object")),
// Dans le cas d'une classe
ClassDeclarationSyntax @class =>
@class.Members.OfType<PropertyDeclarationSyntax>().Select(p => (p.Identifier.Text, p.Type.ToString())),
// Lorsque le type n'est pas reconnu, on renvoie une liste vide
_ => Enumerable.Empty<(string Name, string Type)>()
};
// On renvoie ensuite la liste des propriétés pour le primary constructor de notre record
return string.Join(", ", properties.Select(p => $"{p.Type} {p.Name}"));
}
Il faut ensuite mettre à jour notre StringBuilder pour faire appel à notre méthode nouvellement créée. Attaquons-nous maintenant aux explicit operator pour permettre le mapping de nos DTO.
private static void GenerateMapping(TypeDeclarationSyntax declarationSyntax, string dtoName, string domainName, StringBuilder source)
{
var parameters = declarationSyntax switch
{
RecordDeclarationSyntax record => record.ParameterList?.Parameters,
ClassDeclarationSyntax @class => @class.Members.OfType<ConstructorDeclarationSyntax>().FirstOrDefault()?.ParameterList.Parameters,
_ => Enumerable.Empty<ParameterSyntax>()
};
var parameterSyntaxes = parameters?.ToList();
if (parameterSyntaxes != null && !parameterSyntaxes.Any())
return;
// On ajoute le mapping vers et depuis le dto
AppendExplicitOperator(domainName, dtoName, source, parameterSyntaxes, addBreakLine: true);
AppendExplicitOperator(dtoName, domainName, source, parameterSyntaxes);
}
private static void AppendExplicitOperator(string fromType, string toType, StringBuilder source, IEnumerable<ParameterSyntax> parameters, bool addBreakLine = false)
{
if (addBreakLine) source.AppendLine();
// On recupère la liste des propriétés
var parameterMappings = parameters.Select(p => $"model.{p.Identifier.ValueText.ToPascalCase()}");
// On ajoute l'opérateur explicit pour le mapping
source.AppendLine($" public static explicit operator {toType}({fromType} model) => new({string.Join(", ", parameterMappings)});");
}
Voilà ! Notre IIncrementalGenerator
est prêt et fonctionnel. En l'état il nous permet de créer des dtos avec 2 méthodes basiques pour convertir d'un type à l'autre, sans avoir à écrire tout ce code boilerplate à la main.
SourceGenerator
au travers d'un exemple simple. Le lien vers le code source est disponible en fin d'article, n'hésitez pas à le télécharger et vous amuser avec pour l'enrichir, le refactorer et dites m'en des nouvelles via Github ou Linkedin. 😄 Ça m'intéresseComparatif entre ISourceGenerator et IIncrementalGenerator
Maintenant que nous avons explorer les deux apis, il convient de faire un petit comparatif entre elles pour mieux comprendre leurs forces et leurs différences.
💻 Compatibilité
ISourceGenerator: .NET 5 et version ultérieure.
IIncrementalGenerator: .NET6 et version ultérieure.
🏎️ Performance
ISourceGenerator: Analyse l'ensemble du code à chaque compilation pour générer du code. Plus la solution est grosse, moins il est performant car il doit générer l'arbre syntaxique et le modèle syntaxique de l'ensemble du projet.
IIncrementalGenerator: Fonctionne de manière incrémentielle, génère du code en fonction des modifications apportées au code source. Plus performant pour les grosses solution car il ne génère du code que pour les parties du code source qui ont été modifiées.
🧰 Debug
ISourceGenerator: Plus difficile à debug en raison de l'absence de granularité. C'est à dire que le code est généré en une fois. Si un problème survient lors de cette génération, il est difficile d'identifier facilement d'où provient le problème.
IIncrementalGenerator: Plus facile à debug grâce à la possibilité de suivre les étapes de génération. Ici, la génération se fait en plusieurs étapes qu'il est possible d'isoler et suivre lors du debug. Mais nous verrons cela plus en détail dans un futur article.
Conclusion
ISourceGenerator
est plus facile à utiliser et plus largement supporté, tandis que IIncrementalGenerator
offre une meilleure performance et une plus grande flexibilité. Cette API ne se contente pas de pallier les limitations de ISourceGenerator
, mais elle ouvre également la voie à des possibilités de génération de code plus performantes et plus précises.
Je vous encourage à expérimenter les SourceGenerator
au travers d'IIncrementalGenerator
, à explorer ses capacités et à l'intégrer dans vos projets pour bénéficier de ses avantages en termes de performance et de flexibilité.
Pour aller plus loin
Si vous souhaitez télécharger l'exemple étudié au travers de cet article, je vous invite à suivre le lien suivant :
Lorsque je travaillais sur la rédaction de cette série d'article, je suis tombé sur un tweet de David Fowler partageant ce repo Github qui liste un certain nombre de ressources, de la documentation et des projets Source Generators
. N'hésitez pas à y faire un tour, il est super intéressant et riche en ressources.
Vous pouvez également faire un tour sur le repo dotnet dans lequel vous trouverez toute la documentation nécessaire sur les SourceGenerators
, laissez-vous guider en suivant ce lien :
N'hésitez pas à partager en commentaire, sur Github ou sur LinkedIn vos usages des SourceGenerators
. 😄
Subscribe to my newsletter
Read articles from Yassine FERNANE directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Yassine FERNANE
Yassine FERNANE
Développeur passionné avec près de 10 ans d'expérience sur .NET, je me consacre à promouvoir le clean code, la clean architecture et les bonnes pratiques de développement. Mon blog est le fruit de mon expertise et de ma curiosité insatiable pour l'apprentissage continu. Je partage avec vous mes découvertes, astuces et connaissances pour vous aider à créer un code de qualité et à devenir un développeur .NET accompli