Les Source Generators par l'exemple : ISourceGenerator

Les Source Generators par l'exemple : ISourceGenerator

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.

🔗
Avant toute chose, si vous n'avez pas encore lu la première partie, je vous invite à le faire en suivant ce lien 👉 Introduction aux Source Generators

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 :

  1. Demander à nos utilisateurs de créer eux-mêmes les attributs dont ils ont besoin pour utiliser notre generator

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

Attention, ne modifiez pas manuellement vos fichiers générés puisque vos modifications seront écrasées à la compilation.

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 :

Did you find this article valuable?

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