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.

🔗
Avant toute chose, si vous n'avez pas encore lu la partie précédente, c'est par là 👉 Les Source Generators par l'exemple : ISourceGenerator

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 avec SyntaxProvider 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.

🚧
En l'état notre générateur de dto est perfectible. L'idée de cet article n'est pas de créer le générateur de dto parfait, mais de découvrir les 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éresse

Comparatif 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é.

💡
N'oubliez pas de consulter le code source de cet article et de jouer avec pour l'adapter à vos besoins. Vos retours et contributions sont les bienvenus sur GitHub ou LinkedIn.

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. 😄

Did you find this article valuable?

Support Yassine FERNANE by becoming a sponsor. Any amount is appreciated!