<
Media
>
Article

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

7 min
30
/
09
/
2021

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 :

  • <span class="css-span">Microsoft.CodeAnalysis.CSharp</span>
  • <span class="css-span">Microsoft.CodeAnalysis.Analyzers</span>

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 <span class="css-span">PropertyGroup</span> (celui qui contient <span class="css-span">TargetFramework</span>) une balise <span class="css-span">IsRoslynComponent</span> qui prendra la valeur <span class="css-span">true</span>.

Pour un résultat ressemblant à :

<pre><code>&lt;Project Sdk="Microsoft.NET.Sdk">
&lt;PropertyGroup>
     &lt;TargetFramework>netstandard2.0&lt;/TargetFramework>
     &lt;IsRoslynComponent>true&lt;/IsRoslynComponent>
     &lt;LangVersion>9.0&lt;/LangVersion>
&lt;/PropertyGroup>
[...]<code><pre>

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 :

<pre><code>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)
       {

       }
   }
}<code><pre>

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

  • L'attribut <span class="css-span">[Generator]</span> qui indique que cette classe devra être exécutée comme générateur de code
  • L'interface implémentée <span class="css-span">ISourceGenerator</span>

La méthode <span class="css-span">Initialize</span> est exécutée pendant que le générateur parcourt le code.

La méthode <span class="css-span">Execute</span> 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 <span class="css-span">.csproj</span> manuellement.

Nous allons ajouter un nœud <span class="css-span">ItemGroup</span> contenant une référence de projet.

<pre><code>&lt;ItemGroup>
     &lt;ProjectReference Include="path-to-sourcegenerator-project.csproj"
          OutputItemType="Analyzer"
          ReferenceOutputAssembly="false" />
&lt;/ItemGroup><code><pre>

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

  • <span class="css-span">OutputTypeItem</span>
  • <span class="css-span">ReferenceOutputAssembly</span>

Le second doit prendre la valeur <span class="css-span">true </span>si vous référencez des types qui sont compris dans l'assembly contenant le générateur de code ; sinon, <span class="css-span">false</span> 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 <span class="css-span">HelloWorld</span>, 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 à :

<pre><code>&lt;!--HelloGeneratedWorld.csproj (notre application console)-->
&lt;Project Sdk="Microsoft.NET.Sdk">

     &lt;PropertyGroup>
          &lt;OutputType>Exe&lt;/OutputType>
          &lt;TargetFramework>net5.0&lt;/TargetFramework>
     &lt;/PropertyGroup>
     &lt;ItemGroup>
          &lt;ProjectReference Include="..\SourceGenerators\SourceGenerators.csproj"
               OutputItemType="Analyzer"
               ReferenceOutputAssembly="false"/>
     &lt;/ItemGroup>
&lt;/Project><code><pre>

<pre><code>&lt;!--SourceGEnerators.csproj (notre librairie .NET Standard)-->
&lt;Project Sdk="Microsoft.NET.Sdk">

     &lt;PropertyGroup>
          &lt;TargetFramework>netstandard2.0&lt;/TargetFramework>
          &lt;IsRoslynComponent>true&lt;/IsRoslynComponent>
          &lt;LangVersion>9.0&lt;/LangVersion>
     &lt;/PropertyGroup>

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

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 <span class="css-span">HelloWorld</span> mis au goût du jour est donc le suivant :

<pre><code>// 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));
       }
   }
}<code><pre>

Et son utilisation dans notre application console :

<pre><code>// Program.cs
using System;

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

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 :

<span class="css-span">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</span>

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é <span class="css-span">ReferenceOutputAssembly</span>, une capture du dossier de sortie après un premier debug :

Capture Solution

On ne trouve pas d'assembly <span class="css-span">SourceGenerators</span>.

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 :

<pre><code>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
{
[...]<code><pre>

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 <span class="css-span">partial</span> qui ressemblera à :

<pre><code>public partial class Class2
{
   public void M1()
   {
       this._wrapper.M1(this);
   }
   public void M2()
   {
       this._wrapper.M2(this);
   }
}<code><pre>

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 <span class="css-span">partial</span> pour les classes identifiées.

Nous allons tout rédiger dans la méthode <span class="css-span">Execute</span> du générateur.

Exclure les arbres qui ne contiennent aucune classe annotée

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

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.

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

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 :

<pre><code>var semanticModel = context.Compilation.GetSemanticModel(tree);<code><pre>

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

<pre><code>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();<code><pre>

Note : <span class="css-span">attributeSymbol</span> est une variable définie au début de ma méthode <span class="css-span">Execute</span> qui contient le <span class="css-span">Type</span> 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 <span class="css-span">IdentifierToken</span> 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 <span class="css-span">Type</span>, d'où la comparaison par le nom).

Pour l'étape suivante, nous aurons besoin des <span class="css-span">IdentifiersToken</span>. Nous allons donc nous appuyer sur l'opérateur "Elvis" <span class="css-span">(?.)</span> 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 <span class="css-span">IdentifiersToken</span> 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.

<pre><code>var relatedClass = semanticModel.GetTypeInfo(nodes.Last().Parent);<code><pre>

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 <span class="css-span">relatedClass.Type.Name</span>.

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 <span class="css-span">MethodDeclaration</span> :

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

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

<pre><code>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<code><pre>

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, <span class="css-span">RelatedModelAttribute</span> correspond au <span class="css-span">CustomAttribute</span> des exemples ci-dessus.

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

<pre><code>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(
@"    }
}");
       }
   }
}<code><pre>

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 <span class="css-span">CompilerGeneratedFilesOutputPath</span> dans un <span class="css-span">PropertyGroup</span> (celui par défaut contenant les frameworks cibles fonctionne parfaitement).

La valeur du nœud indique le chemin de sortie

Exemple :

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

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 <span class="css-span">None</span>, dans un <span class="css-span">ItemGroup</span>.

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 :

<pre><code>&lt;ItemGroup>
     &lt;None Include="Generated\**" />
&lt;/ItemGroup><code><pre>

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

No items found.
ça t’a plu ?
Partage ce contenu
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.

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