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 :
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 :
- Au chargement du projet référençant le générateur
- 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 :
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 :
- On recherche et parcourt les nœuds de type déclaration de classe.
- 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#.