Génération de code source C# en se basant sur des attributs

Ce que l'on va découvrir

Nous allons voir comment :

  • Régler son environnement pour créer et déboguer un générateur de code C#.
  • Développer un générateur de code qui se base sur des annotations pour générer une classe pendant la phase de build.

Prérequis

  • Visual Studio 2019 16.10+

Et pour le debug :

  • Charge de travail "Développement d'extensions pour Visual Studio"
  • Composant individuel "SDK .NET Compiler Platform"

Créer un projet de génération de code

Le projet

Créer un projet de type "bibliothèque de code" (library) en .NET Standard 2.0.

Ajouter les références suivantes via nuget :

  • Microsoft.CodeAnalysis.CSharp
  • Microsoft.CodeAnalysis.Analyzers

NOTE : Les versions de ces packages sont liées à une version du SDK dotnet précise. Pour assurer le bon fonctionnement du générateur, bien vérifier que le SDK installé est la dernière version avant d'installer les packages. Une fois les packages installés, ne pas les mettre à jour sans mettre également le SDK à jour.

Il faudra également éditer le fichier projet pour ajouter dans le nœud PropertyGroup (celui qui contient TargetFramework) une balise IsRoslynComponent qui prendra la valeur true.

Pour un résultat ressemblant à :

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

<PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <IsRoslynComponent>true</IsRoslynComponent>
    <LangVersion>9.0</LangVersion>
</PropertyGroup>
[...]

NOTE : La version minimum du langage nécessaire pour utiliser les générateurs de code est la version 9.0. Il faudra donc que tous les projets concernés (ceux exposant des générateurs ou ceux qui les consomment utilisent a minima cette version du langage).

Le template

Maintenant que notre projet est configuré pour se comporter comme un projet de générateur de code, il nous reste plus qu'à en créer un exemple.

Le template de base est le suivant :

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace MyGeneratorNameSpace
{
    [Generator]
    public class MyGenerator : ISourceGenerator
    {
        public void Initialize(GeneratorInitializationContext context)
        {

        }

        public void Execute(GeneratorExecutionContext context)
        {

        }
    }
}

Deux éléments sont particulièrement importants ici :

  • L'attribut [Generator] qui indique que cette classe devra être exécutée comme générateur de code
  • L'interface implémentée ISourceGenerator

La méthode Initialize est exécutée pendant que le générateur parcourt le code.

La méthode Execute est exécutée une fois que le code a fini d'être parcouru, c'est ici que le code sera effectivement généré.

Référencer un générateur de code

Pour référencer un générateur de code dans un projet, il est nécessaire d'éditer le fichier .csproj manuellement.

Nous allons ajouter un nœud ItemGroup contenant une référence de projet.

<ItemGroup>
    <ProjectReference Include="path-to-sourcegenerator-project.csproj" 
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false" />
</ItemGroup>

Cela ressemble à une référence de projet classique avec deux attributs supplémentaires obligatoires:

  • OutputTypeItem
  • ReferenceOutputAssembly

Le second doit prendre la valeur true si vous référencez des types qui sont compris dans l'assembly contenant le générateur de code ; sinon, false permet de ne pas avoir de dépendance à l'assembly contenant le générateur de code dans les assemblys le référençant.

Hello World

Maintenant que nous avons vu la théorie, passons à la pratique pour mettre en place un projet console qui référencera un projet de générateur de code qui nous affichera un HelloWorld ainsi que la liste des arbres syntaxiques connus.

La solution

Nous allons nous atteler à créer notre solution HelloWorld, celle-ci contiendra :

  • un projet console .NET 5.0
  • un projet de type bibliothèque ciblant .NET Standard 2.0

Si l'on applique ce que l'on a précédemment évoqué, les deux fichiers projets auront un contenu similaire à :

<!--HelloGeneratedWorld.csproj (notre application console)-->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\SourceGenerators\SourceGenerators.csproj"
                      OutputItemType="Analyzer"
                      ReferenceOutputAssembly="false"/>
  </ItemGroup>
</Project>
<!--SourceGEnerators.csproj (notre librairie .NET Standard)-->
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <IsRoslynComponent>true</IsRoslynComponent>
    <LangVersion>9.0</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.11.0" />
  </ItemGroup>

</Project>

Nous allons nous inspirer d'un billet de blog par Philippe Carter introduisant les générateurs de source.

Le code de génération de notre HelloWorld mis au goût du jour est donc le suivant :

// HelloWorldGenerator.cs
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace SourceGenerators
{
    [Generator]
    public class MyGenerator : ISourceGenerator
    {
        public void Initialize(GeneratorInitializationContext context)
        {

        }

        public void Execute(GeneratorExecutionContext context)
        {
            // begin creating the source we'll inject into the users compilation
            var sourceBuilder = new StringBuilder(@"
using System;
namespace HelloWorldGenerated
{
    public static class HelloWorld
    {
        public static void SayHello() 
        {
            Console.WriteLine(""Hello from generated code!"");
            Console.WriteLine(""The following syntax trees existed in the compilation that created this program:"");
");

            // using the context, get a list of syntax trees in the users compilation
            var syntaxTrees = context.Compilation.SyntaxTrees;

            // add the filepath of each tree to the class we're building
            foreach (SyntaxTree tree in syntaxTrees)
            {
                sourceBuilder.AppendLine($@"Console.WriteLine(@"" - {tree.FilePath}"");");
            }

            // finish creating the source to inject
            sourceBuilder.Append(@"
        }
    }
}");

            // inject the created source into the users compilation
            context.AddSource("helloWorldGenerator", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8));
        }
    }
}

Et son utilisation dans notre application console :

// Program.cs
using System;

namespace HelloGeneratedWorld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
            HelloWorldGenerated.HelloWorld.SayHello();
        }
    }
}

Votre solution devrait ressembler à la capture ci-dessous :

Capture Solution

Si vous avez appliqué les instructions, sans avoir lancé le débogage (ou la compilation) de l'application console, Visual Studio doit vous indiquer qu'il ne connait pas les types !

C'est normal, même si contre-intuitif et pas forcément pratique.

De ce que j'ai pu constater, la génération de code se fait à deux principaux moments :

  1. Au chargement du projet référençant le générateur
  2. A la compilation

Ce qui veut dire... Que lancer la compilation dans cet état ne renverra pas d'erreur, et mieux que ça, vous affichera les arbres syntaxiques connus par le générateur

Par exemple :

Hello World!
Hello from generated code!
The following syntax trees existed in the compilation that created this program:
 - C:\SourceGenerators\Examples\HelloGeneratedWorld\Program.cs
 - C:\SourceGenerators\Examples\HelloGeneratedWorld\obj\Debug\net5.0\.NETCoreApp,Version=v5.0.AssemblyAttributes.cs
 - C:\SourceGenerators\Examples\HelloGeneratedWorld\obj\Debug\net5.0\HelloGeneratedWorld.AssemblyInfo.cs

La solution complète prête à être utilisée est disponible dans ce dépôt Github dans la branche HelloWorld.

NOTE : J'utilisais le SDK .NET 5.0.400 quand j'ai créé le projet, si vous avez une version différente, vous pourriez être amenés à devoir changer la version des packages nuget du projet SourceGenerators pour que tout fonctionne correctement.

Pour illustrer la note concernant la propriété ReferenceOutputAssembly, une capture du dossier de sortie après un premier debug :

Capture Solution

On ne trouve pas d'assembly SourceGenerators.

Un cran plus loin : analysons les arbres syntaxiques

Cette section est basée sur ma propre question/réponse disponible sur StackOverflow.

Nous allons dans un premier temps, introduire le problème :

public class CustomAttribute : Attribute
{
   [...]
   public CustomAttribute(Type type)
   {
    [...]
   }
}

[Custom(typeof(Class2))]
public class Class1
{
    public void M1(Class2) {}
    public void M2(Class2) {}
}


public partial class Class2
{
[...]

L'idée est d'avoir un attribut que nous allons pouvoir placer sur une classe afin de générer de générer du code pour dans une autre classe qui sera partielle et qui contiendra une instance de la Class1.

L'idée est de générer un partial qui ressemblera à :

public partial class Class2
{
    public void M1()
    {
        this._wrapper.M1(this);
    }

    public void M2()
    {
        this._wrapper.M2(this);
    }
}

Maintenant que l'objectif est posé, voyons comment nous allons pouvoir :

  • trouver quelles classes sont marquées par ces attributs,
  • lister les méthodes, leurs types de retours et leurs paramètres,
  • générer la source pour le partial pour les classes identifiées.

Nous allons tout rédiger dans la méthode Execute du générateur.

Exclure les arbres qui ne contiennent aucune classe annotée

var treesWithlassWithAttributes = 
    context.Compilation.SyntaxTrees
        .Where(st => 
            st.GetRoot()
                .DescendantNodes()
                .OfType<ClassDeclarationSyntax>()
                .Any(p => 
                    p.DescendantNodes()
                        .OfType<AttributeSyntax>()
                        .Any()));

En lisant cet extrait, on se rend compte tout de suite que LinQ va être un de nos meilleurs amis et que la navigation fonctionne un peu comme LinQToXML.

Par étapes :

  1. On recherche et parcourt les nœuds de type déclaration de classe.
  2. On recherche les nœuds qui appartiennent à la classe des nœuds de type attribut.

Et on ne retourne que les arbres syntaxiques qui ont donc des classes possédant au moins un attribut.

Retirer les classes qui ne sont pas annotées

Rien n'empêche en C# d'avoir plusieurs classes définies dans le même fichier.

Cette étape consiste donc à exclure toutes les classes des arbres syntaxiques retenus qui n'ont pas d'attributs.

var declaredClass = tree
                    .GetRoot()
                    .DescendantNodes()
                    .OfType<ClassDeclarationSyntax>()
                    .Where(cd => cd.DescendantNodes().OfType<AttributeSyntax>().Any())

Ici, tree correspond à un des arbres syntaxiques sélectionnés précédemment.

Nous descendons une fois encore pour sélectionner les nœuds de type déclaration de classe, mais uniquement ceux qui sont décorés par des attributs.

Retirer les classes qui ne sont pas annotées par notre attribut

Nous allons commencer par initialiser un modèle sémantique correspondant à notre arbre syntaxique.

Ceci va nous permettre notamment :

  • de rechercher des types,
  • d'obtenir facilement des informations sur les types sans avoir à parcourir l'arbre syntaxique.

Cela se fait simplement :

var semanticModel = context.Compilation.GetSemanticModel(tree);

Maintenant que notre modèle est initialisé, retournons à nos moutons :

var nodes = declaredClass
    .DescendantNodes()
    .OfType<AttributeSyntax>()
    .FirstOrDefault(
        a => a.DescendantTokens().Any(dt => dt.IsKind(SyntaxKind.IdentifierToken) 
        && semanticModel.GetTypeInfo(dt.Parent).Type.Name == attributeSymbol.Name))
    ?.DescendantTokens()
    ?.Where(dt => dt.IsKind(SyntaxKind.IdentifierToken))
    ?.ToList();

Note : attributeSymbol est une variable définie au début de ma méthode Execute qui contient le Type de l'attribut qui nous intéresse.

Nous repartons donc de la déclaration de notre classe dans notre arbre syntaxique, pour rechercher les attributs.

On va ensuite sélectionner la première déclaration d'attribut qui a un IdentifierToken dont le nœud parent est du type de l'attribut (on notera la comparaison par nom : l'API sémantique ne permet pas d'obtenir un Type, d'où la comparaison par le nom).

Pour l'étape suivante, nous aurons besoin des IdentifiersToken. Nous allons donc nous appuyer sur l'opérateur "Elvis" (?.) pour propager les valeurs nulles éventuelles, ce qui nous permettra de passer directement à l'itération suivante de notre boucle.

Récupérer le type de classe utilisé comme paramètre de l'attribut

L'étape précédente nous a permis d'obtenir, dans la variable nodes, les IdentifiersToken correspondant à l'utilisation de l'attribut.

Un premier identifiant représentant le nom de l'attribut, puis un second qui correspond au nom de la classe passée en paramètre.

Pour obtenir les détails de la classe qui nous intéresse sans avoir à reparcourir tous nos arbres syntaxiques, nous allons à nouveau nous appuyer sur le modèle sémantique que nous avons initialisé plus tôt.

var relatedClass = semanticModel.GetTypeInfo(nodes.Last().Parent);

Puisque nous avons maintenant obtenu le nom de la classe, il est possible de commencer a générer le code lié à cette classe.

Note : Le nom de la classe peut être obtenu avec relatedClass.Type.Name.

Lister toutes les méthodes présentes dans la classe

Nous allons maintenant lister toutes les méthodes de la classe annotée (celle sur laquelle nous sommes en train de travailler au travers de l'arbre syntaxique, pas celle que nous venons de trouver dans le modèle syntaxique).

La première étape est relativement simple : lister tous les membres de la classe qui sont de type MethodDeclaration :

IEnumerable<MethodDeclarationSyntax> classMethod = declaredClass.Members.Where(m => m.IsKind(SyntaxKind.MethodDeclaration)).OfType<MethodDeclarationSyntax>()

Pour notre cas, il va être nécessaire de caster explicitement vers le type MethodDeclarationSyntax. Le type de base stocké dans la collection Members est typé moins spécifiquement et n'expose pas les propriétés dont nous aurons besoin :

methodDeclaration.Modifiers //public, static, etc...
methodDeclaration.Identifier // Plutôt évident => le nom
methodDeclaration.ParameterList // La liste des paramètres, incluant type, nom, valeurs par défaut

Le reste de l'étape consiste juste à créer une chaine représentant la classe partielle dont j'avais besoin.

La solution finale

Voici donc la solution complète après avoir suivi toutes ces étapes :

Note : Dans le code suivant, RelatedModelAttribute correspond au CustomAttribute des exemples ci-dessus.

Note : Ce code fait partie d'un de mes projets sur GitHub.

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using SpeedifyCliWrapper.SourceGenerators.Annotations;
using System.Linq;
using System.Text;

namespace SpeedifyCliWrapper.SourceGenerators
{
    [Generator]
    class ModuleModelGenerator : ISourceGenerator
    {
        public void Execute(GeneratorExecutionContext context)
        {
            var attributeSymbol = context.Compilation.GetTypeByMetadataName(typeof(RelatedModelAttribute).FullName);

            var classWithAttributes = context.Compilation.SyntaxTrees.Where(st => st.GetRoot().DescendantNodes().OfType<ClassDeclarationSyntax>()
                    .Any(p => p.DescendantNodes().OfType<AttributeSyntax>().Any()));

            foreach (SyntaxTree tree in classWithAttributes)
            {
                var semanticModel = context.Compilation.GetSemanticModel(tree);

                foreach(var declaredClass in tree
                    .GetRoot()
                    .DescendantNodes()
                    .OfType<ClassDeclarationSyntax>()
                    .Where(cd => cd.DescendantNodes().OfType<AttributeSyntax>().Any()))
                {
                    var nodes = declaredClass
                    .DescendantNodes()
                    .OfType<AttributeSyntax>()
                    .FirstOrDefault(a => a.DescendantTokens().Any(dt => dt.IsKind(SyntaxKind.IdentifierToken) && semanticModel.GetTypeInfo(dt.Parent).Type.Name == attributeSymbol.Name))
                    ?.DescendantTokens()
                    ?.Where(dt => dt.IsKind(SyntaxKind.IdentifierToken))
                    ?.ToList();

                    if(nodes == null)
                    {
                        continue;
                    }

                    var relatedClass = semanticModel.GetTypeInfo(nodes.Last().Parent);

                    var generatedClass = this.GenerateClass(relatedClass);

                    foreach(MethodDeclarationSyntax classMethod in declaredClass.Members.Where(m => m.IsKind(SyntaxKind.MethodDeclaration)).OfType<MethodDeclarationSyntax>())
                    {
                        this.GenerateMethod(declaredClass.Identifier, relatedClass, classMethod, ref generatedClass);
                    }

                    this.CloseClass(generatedClass);

                    context.AddSource($"{declaredClass.Identifier}_{relatedClass.Type.Name}", SourceText.From(generatedClass.ToString(), Encoding.UTF8));
                }
            }
        }

        public void Initialize(GeneratorInitializationContext context)
        {
            // Nothing to do here
        }

        private void GenerateMethod(SyntaxToken moduleName, TypeInfo relatedClass, MethodDeclarationSyntax methodDeclaration, ref StringBuilder builder)
        {
            var signature = $"{methodDeclaration.Modifiers} {relatedClass.Type.Name} {methodDeclaration.Identifier}(";

            var parameters = methodDeclaration.ParameterList.Parameters.Skip(1);

            signature += string.Join(", ", parameters.Select(p => p.ToString())) + ")";

            var methodCall = $"return this._wrapper.{moduleName}.{methodDeclaration.Identifier}(this, {string.Join(", ", parameters.Select(p => p.Identifier.ToString()))});";

            builder.AppendLine(@"
        " + signature + @"
        {
            " + methodCall + @"
        }");
        }

        private StringBuilder GenerateClass(TypeInfo relatedClass)
        {
            var sb = new StringBuilder();

            sb.Append(@"
using System;
using System.Collections.Generic;
using SpeedifyCliWrapper.Common;

namespace SpeedifyCliWrapper.ReturnTypes
{
    public partial class " + relatedClass.Type.Name);

            sb.Append(@"
    {");

            return sb;
        }
        private void CloseClass(StringBuilder generatedClass)
        {
            generatedClass.Append(
@"    }
}");
        }
    }
}

Astuces bonus

Le code généré n'est pas facilement consultable ni facile à découvrir.

Il est cependant possible d'expliciter le chemin de sortie des fichiers générés. Cela pose un second problème : si le chemin de la génération n'est pas ignoré, le compilateur va essayer de les compiler en plus de ceux qui sont générés à la volée par le générateur de code. Il faut donc ignorer le dossier de sortie.

Ces modifications sont à effectuer dans les fichiers projets référençant le générateur de code.

Pour indiquer le dossier où l'on veut que les fichiers générés soient stockés, il faut ajouter un nœud CompilerGeneratedFilesOutputPath dans un PropertyGroup (celui par défaut contenant les frameworks cibles fonctionne parfaitement).

La valeur du nœud indique le chemin de sortie

Exemple :

<PropertyGroup>
    <TargetFrameworks>net5.0;netstandard2.1</TargetFrameworks>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
    <CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath><!--Ajouter cette ligne-->
</PropertyGroup>

Pour la seconde partie du problème, ignorer ce dossier de sortie, il faut ajouter une ligne indiquant que tous les fichiers contenus dans ce dossier ne font pas partie de la compilation.

Il s'agit d'un classique nœud None, dans un ItemGroup.

Il suffit donc de rajouter l'extrait XML suivant dans le fichier csproj pour ignorer tous les fichiers présents dans le dossier de sortie :

<ItemGroup>
    <None Include="Generated\**" />
</ItemGroup>

Pour aller plus loin

Un autre exemple de générateur de codes utile serait pour la génération de tests unitaires qui peuvent être nécessaires mais extrêmement répétitifs (si votre politique de TU vous demande de tester les accesseurs par exemple). Un très bon article (en anglais) de Jonathan Allen indique comment mettre en place une telle solution : "Building a Source Generator for C#.

#C#

Antoine-Ali

Chez Younup, quand on a une question sur .NET, c’est vers Antoine-Ali que l’on se tourne. S’il est tombé amoureux de cette techno, c’est avant tout pour son écosystème complet qui selon lui évolue vite et dans le bon sens.

Vrai passionné de code, il a toujours plein de projets en tête pour pouvoir tester tout un tas de technos différentes. Sa dernière lubie : trouver une idée de projet pour tester CUDA/OpenCL.

Apprendre et s’amuser en développant, c’est son credo. « Écrire du code, c’est bien, le faire de manière fun, c’est mieux ».

Retours aux publications