Analyse technique de Roslyn : utilisation pour la vérification des normes de code et l'exécution de code dynamique

  1. Qu'est-ce que Roslyn

Pour certains développeurs, Roslyn peut être un concept peu familier. Certains ont entendu parler mais n'ont jamais utilisé, tandis que d'autres le maîtrisent bien. Avant de me plonger dans ce sujet, je me trouvais dans la première catégorie : j'avais entendu parler de Roslyn mais sans jamais approfondir. Pour les tâches quotidiennes comme le CRUD, ce n'était pas nécessaire. Cependant, si vous souhaitez approfondir le développement de frameworks, d'outils d'efficacité ou d'extensions, Roslyn est une technologie essentielle à maîtriser.

Au début de l'année, lors d'une discussion avec un expert technique nommé Chizhe Gongliang, j'ai officiellement découvert Roslyn et j'ai été immédiatement fasciné par ses capacités puissantes. Son explication claire et accessible m'a fait réaliser que ce n'était pas seulement une technologie de compilation avancée, mais aussi un outil puissant pour améliorer la qualité du code et l'efficacité du développement. Inspiré par lui, j'ai commencé à étudier systématiquement Roslyn. Bien que mon apprentissage ait été intermittent, j'ai finalement pris le temps de synthétiser mes connaissances dans cet article, à la fois comme un enregistrement et comme un hommage à l'aide de mon ami Yan Jia.

Avant de comprendre véritablement Roslyn, nous devons d'abord avoir une compréhension générale du processus de compilation C#/.NET, qui s'applique également à VB.NET.

Processus de compilation C#/.NET simplifié

  1. Phase de code source : Nous écrivons manuellement du code C# ou VB.NET
  2. Phase du compilateur : Le compilateur Roslyn convertit le code source en IL (Intermediate Language)
  3. Génération IL : Création de fichiers .dll ou .exe contenant le code IL et les métadonnées
  4. Compilation à l'exécution : Le CLR compile l'IL en code machine natif via JIT pour exécution

Il suffit de comprendre ce processus général. Les détails plus approfondis, y compris AOT/JIT, seront abordés dans un autre article. C'est ici que Roslyn intervient : son rôle est de compiler le code C# natif en IL. On peut le considérer comme une plateforme de compilation open source, écrite elle-même en C# (auto-compilation, similaire au paradoxe de la poule et de l'œuf). Cette situation a commencé à émerger après la création de Roslyn par Microsoft, tandis que les compilateurs anciens étaient écrits en C++.

Questions fréquentes

Q1 : Roslyn peut-il compiler d'autres types de code ? Puis-je concevoir mon propre langage à compiler avec Roslyn, ou est-il limité à C# et VB.NET ?

En fait, Roslyn peut uniquement compiler C# et VB.NET. Si nous définissions un langage X, nous ne pourrions pas l'utiliser avec Roslyn, sauf à nous inspirer de Roslyn pour écrire notre propre analyseur.

Q2 : Comment Roslyn compile-t-il le code C# en IL ? Quel est son processus de compilation ?

  1. Analyse syntaxique (Parsing) → Génération d'un arbre syntaxique (Syntax Tree)
  2. Analyse sémantique (Semantic Analysis) → Génération de symboles et liaisons
  3. Génération IL (Code Generation) → Émission d'IL
  4. Applications de Roslyn

Bien que Roslyn puisse servir de compilateur pour le code C#, ses applications en tant que plateforme open source vont bien au-delà. Nous présentons ici quelques exemples simples, avec des articles plus détaillés à venir.

Fonctionnalités

  • Arbre syntaxique (Syntax Tree) : Analyse du code source sous forme de représentation structurée
  • Modèle sémantique (Semantic Model) : Fournit une compréhension des symboles et de leur signification dans le code
  • Diagnostic (Diagnostics) : Permet aux développeurs de créer des règles de vérification personnalisées à la compilation
  • Outils de refactorisation : Prend en charge le développement d'outils de refactorisation comme les corrections automatiques et le nettoyage de code
  • Génération de code : Permet de générer de nouveaux fichiers de code ou de modifier des fichiers existants

Cas d'utilisation

  • Développement d'extensions pour Visual Studio
  • Création d'outils d'analyse statique comme SonarLint, ReSharper et GitHub Code Scanning
  • Implémentation d'outils de vérification de la qualité du code, comme la détection de code interdit par l'équipe ou les appels de base de données en boucle
  • Construction d'outils de génération de code, utilisant des générateurs de sources lors de la phase de compilation
  • Compilation et exécution dynamiques de code à l'exécution, permettant à l'utilisateur de saisir une chaîne de code C# qui est immédiatement compilée et exécutée
  1. Analyse syntaxique

Définissons un code C# qui, lors de la compilation, est lu comme une chaîne de caractères. Roslyn lit probablement les fichiers de code comme des chaînes pour les analyser.

using System;
using System.Collections;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
namespace HelloWorld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
        }

        static void Main1()
        {
            Console.WriteLine("HelloMain1!");
        }
    }
}

Lorsque nous ouvrons ce code dans Visual Studio et utilisons un outil de visualisation d'arbre syntaxique, nous comprenons pourquoi on parle d'analyse d'arbre syntaxique. À gauche, le code source ; à droite, la structure d'arbre syntaxique analysée par l'outil. Le premier niveau de nœud racine est appelé [CompilationUnit], ou unité de compilation, correspondant à un fichier unique. L'arbre se développe en forme arborescente, et lorsque vous déplacez la souris sur un élément de code source, le nœud d'arbre correspondant apparaît dans l'outil de visualisation.

Différentes couleurs marquent différents éléments syntaxiques :

  1. Nœuds syntaxiques (SyntaxNode) : marqués en bleu, tels que les méthodes, classes, expressions, etc.
  2. Tokens syntaxiques (SyntaxToken) : marqués en vert, tels que les mots-clés static, void, etc.
  3. Éléments divers syntaxiques (SyntaxTrivia) : marqués en rouge, tels que certains espaces et commentaires
API Syntaxe

Le type Syntax représente la structure syntaxique du code source et constitue la base de la construction et manipulation de l'arbre abstrait de syntaxe (AST) pour C#.

L'arbre syntaxique, du plus grand au plus petit :

  1. Directives using - UsingDirectiveSyntax
  2. Syntaxe des définitions de membres - MemberDeclarationSyntax
  3. Syntaxe des espaces de noms - NamespaceDeclarationSyntax
  4. Syntaxe des définitions de classe - ClassDeclarationSyntax
  5. Syntaxe des définitions de méthode - MethodDeclarationSyntax
  6. Syntaxe des définitions de paramètres - ParameterSyntax
  7. Analyse sémantique

Par exemple, si une méthode retourne un int mais que j'en retourne un string :

static int Main2()
{
    return "1";
}

L'arbre syntaxique visualisé est correctement généré, et l'outil d'analyse en ligne peut également l'analyser. Cependant, en tant que développeurs, nous savons qu'il est impossible d'utiliser un string quand on attend un int - la logique ne correspond pas. Dans Visual Studio, une erreur s'affiche car le détecteur vérifie si le code écrit suit les règles. Si vous écrivez dans le Bloc-notes, il n'y a pas de problème, cela respecte la syntaxe C#, mais il n'y a pas suffisamment d'informations pour identifier ce que signifie le contenu référencé. Un nom peut représenter un type, une méthode ou une variable locale, avec des significations différentes. C'est là qu'intervient un autre concept : l'analyse sémantique. Nous analysons et générons une structure syntaxique, mais nous devons aussi savoir ce que chaque nœud représente et sa signification. Seule la compréhension de la sémantique permet au code de véritablement "prendre vie".

  1. Utilisation des API Roslyn pour l'analyse syntaxique et sémantique

Définissons d'abord une chaîne de code, car Roslyn lit les fichiers de code source comme des chaînes lors de la compilation, formant la structure logique d'arbre syntaxique décrite précédemment.

public const string ProgramText =
   @"using System;
       using System.Collections;
       using System.Linq;
       using System.Text;
       using Microsoft.CodeAnalysis;
       namespace HelloWorld
       {
           class Program
           {
               static void Main(string[] args)
               {
                   Console.WriteLine(""Hello, World!"");
               }

               static void Main1()
               {       
                      var list= new List<string>() { ""21""};
                       list.Add(""c"");
                   Console.WriteLine(""Hello, Main1!"");
               }
           }
       }";

  1. Analyse syntaxique
Obtenir directement le type de retour à partir du nœud syntaxique && Parcourir chaque nœud avec l'arbre syntaxique
static void Main(string[] args)
{
    SyntaxTree tree = CSharpSyntaxTree.ParseText(ProgramText);
    CompilationUnitSyntax root = tree.GetCompilationUnitRoot();
    
    WriteLine($"L'arbre syntaxique contient {root.Members} éléments.");
    WriteLine($"Cet arbre syntaxique a {root.Usings} instructions using, qui sont:");
    foreach (UsingDirectiveSyntax element in root.Usings)
        WriteLine($"\t{element.Name}");

    MemberDeclarationSyntax premierMembre = root.Members[0];
    WriteLine($"Le premier membre est : {premierMembre.Kind()}.");
    var declarationHelloWorld = (NamespaceDeclarationSyntax)premierMembre;

    WriteLine($"L'espace de noms {declarationHelloWorld.Name} déclare {declarationHelloWorld.Members} membres.");
    WriteLine($"Le premier membre est de type : {declarationHelloWorld.Members[0].Kind()}.");
 
    var declarationProgramme = (ClassDeclarationSyntax)declarationHelloWorld.Members[0];
    WriteLine($"Il y a {declarationProgramme.Members} membres définis dans la classe {declarationProgramme.Identifier}.");

    // Obtenir directement le type de retour à partir du nœud syntaxique
    for (int i = 0; i < declarationProgramme.Members.Count; i++)
    {
        WriteLine($"Le {i+1}ème membre est de type {declarationProgramme.Members[i].Kind()}.");
        var declarationMethode = (MethodDeclarationSyntax)declarationProgramme.Members[i];

        WriteLine($" {declarationMethode.Identifier} : le type de retour de la méthode est : {declarationMethode.ReturnType}.");
        WriteLine($"La méthode a : {declarationMethode.ParameterList.Parameters} paramètres.");
        foreach (ParameterSyntax item in declarationMethode.ParameterList.Parameters)
            WriteLine($"Le type du paramètre {item.Identifier} est : {item.Type}.");
        WriteLine($"Le contenu du corps de la méthode {declarationMethode.Identifier} est:");
        WriteLine(declarationMethode.Body.ToFullString());

        if (declarationMethode.ParameterList.Parameters.Any())
        {
            var parametreArgs = declarationMethode.ParameterList.Parameters[0];
            var premiersParametres = from declarationMethode in root.DescendantNodes()
                                                    .OfType<MethodDeclarationSyntax>()
                                  where declarationMethode.Identifier.ValueText == "Main"
                                  select declarationMethode.ParameterList.Parameters.First();

            var parametreArgs2 = premiersParametres.Single();

            WriteLine(parametreArgs == parametreArgs2);
        }

    }
}

Sortie
L'arbre syntaxique contient 1 éléments.
Cet arbre syntaxique a 5 instructions using, qui sont:
        System
        System.Collections
        System.Linq
        System.Text
        Microsoft.CodeAnalysis
        
Le premier membre est : NamespaceDeclaration.
L'espace de noms HelloWorld déclare 1 membres.
Le premier membre est de type : ClassDeclaration.

Il y a 2 membres définis dans la classe Program.
Le 1er membre est de type MethodDeclaration.
     Main : le type de retour de la méthode est : void.
        La méthode a : 1 paramètres.
            Le type du paramètre args est : string[].
                Le contenu du corps de la méthode Main est:
                    {
                        Console.WriteLine("Hello, World!");
                    }

Le 2ème membre est de type MethodDeclaration.
     Main1 : le type de retour de la méthode est : void.
        La méthode a : 0 paramètres.
            Le contenu du corps de la méthode Main1 est:
                {
                    Console.WriteLine("Hello, Main1!");
                }

  1. Analyse sémantique

Continuons avec l'analyse sémantique. En termes simples, une fois que la structure syntaxique est correcte, nous devons comprendre ce que ce code fait réellement. Il suit l'arbre syntaxique, niveau par niveau, pour comprendre la véritable signification de chaque partie, comme qui est la variable, comment la fonction est utilisée, si le type est correct, et enfin pour extraire l'« intention réelle » du programme.

// Générer l'arbre syntaxique pour le texte de code dans la constante programText
SyntaxTree tree = CSharpSyntaxTree.ParseText(ProgramText);
CompilationUnitSyntax root = tree.GetCompilationUnitRoot();
 
var compilation = CSharpCompilation.Create("HelloWorld")
         .AddReferences(MetadataReference.CreateFromFile(
             typeof(string).Assembly.Location))
         .AddSyntaxTrees(tree);
       
var methodes = (from declarationMethode in root.DescendantNodes()
                                                     .OfType<MethodDeclarationSyntax>()
                select declarationMethode).ToList();
      
// Parcourir les nœuds syntaxiques pour trouver toutes les définitions de méthode
foreach (var item in methodes)
{
    // Obtenir le modèle sémantique complet
    var modele = compilation.GetSemanticModel(tree);

    // Trouver le symbole de la méthode actuelle en utilisant le modèle sémantique
    var methodeSémantique = modele.GetDeclaredSymbol(item);
     
    // Obtenir le type de retour de la méthode actuelle
    Console.WriteLine(methodeSémantique.ReturnType);
}

  1. Extensions et applications avancées

Après avoir vu les analyses syntaxique et sémantique, vous pourriez encore être un peu confus en vous demandant à quoi tout cela sert. Ne vous inquiétez pas, je vais vous montrer les avantages de manière aussi claire que possible.

  1. Si vous faites du développement, vous êtes probablement familiarisé avec les transactions dans le code. Dans le développement d'équipe, avez-vous déjà oublié d'écrire Commit() et découvert plus tard que vos opérations étaient inefficaces ?
  2. Dans le développement d'équipe, le code de certaines personnes ne respecte jamais les exigences. On demande des minuscules pour les paramètres, ils utilisent des majuscules. On demande des majuscules pour les noms de méthode, ils utilisent des minuscules. Après un certain temps, le code devient pénible à lire, ou après le déploiement, l'utilisation de méthodes asynchrones retournant void provoque des exceptions mystérieuses, passant des heures à déboguer avant de finalement revenir en arrière.

En tant que leader technique ou développeur senior, votre temps est limité. Il est impossible de surveiller chaque développeur. Que faire ?

À ce stade, vous vous demandez si vous pourriez empêcher l'écriture de tel code dès le début. Comme dans Visual Studio où une erreur s'affiche immédiatement, comment faire ? Félicitations, vous avez commencé ! À ce moment, vous pouvez définir votre propre analyseur de code pour vérifier ces problèmes. Nous avons déjà mentionné qu'une application importante de Roslyn est l'analyse de code. Les caractéristiques et les fonctions de Roslyn que nous avons comprises dans les parties d'analyse syntaxique et sémantique nous donnent une piste. La logique est la suivante :

  • Analysez notre code avec Roslyn pour en extraire la structure syntaxique et le modèle sémantique
  • En fonction de l'arbre syntaxique, trouvez tous les nœuds de méthode, puis utilisez l'analyse sémantique pour trouver toutes les méthodes Task et signaler celles qui correspondent et retournent void

Mais ne vous limitez pas à cela, car Roslyn offre bien plus que ce que nous décrivons simplement.

  1. Création d'outils de vérification de normes et de compilation dynamique avec Roslyn
  2. Construction d'un vérificateur de normes de code avec Roslyn : interdiction des méthodes async void
① Création d'un analyseur personnalisé

Nous définissons une classe AnalyseurDiagnosticUtilisateur qui hérite de DiagnosticAnalyzer et est annotée par [DiagnosticAnalyzer(LanguageNames.CSharp)], indiquant qu'il s'agit d'un analyseur syntaxique pour le langage C#.

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class AnalyseurDiagnosticUtilisateur : DiagnosticAnalyzer
{
    // ...
}

② Définition des règles de diagnostic

Nous définissons une règle de diagnostic via DiagnosticDescriptor qui affiche un message d'erreur lorsque du code non conforme est détecté, par exemple "Les méthodes asynchrones ne peuvent pas retourner void".

private static DiagnosticDescriptor Règle = new DiagnosticDescriptor(
    id: "Code001",
    title: "Titre de la règle d'exemple",
    messageFormat: "Vérification des normes de code : {0}",
    category: "Usage",
    defaultSeverity: DiagnosticSeverity.Error,
    isEnabledByDefault: true
);

③ Enregistrement de la logique d'analyse

Dans la méthode Initialize, nous enregistrons une action d'analyse de nœud syntaxique qui écoute toutes les déclarations de méthode (MethodDeclaration) :

public override void Initialize(AnalysisContext context)
{
    context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
    context.EnableConcurrentExecution();
    context.RegisterSyntaxNodeAction(AnalyserContexteSymbol, SyntaxKind.MethodDeclaration);
}

  • EnableConcurrentExecution() améliore les performances d'analyse
  • RegisterSyntaxNodeAction spécifie que lorsque une déclaration de méthode est analysée, la méthode AnalyserContexteSymbol est appelée pour analyse
④ Implémentation de la logique d'analyse

Dans la méthode de rappel, nous vérifions si la méthode satisfait simultanément deux conditions :

  • Contient le mot-clé async
  • Le type de retour est void
private static void AnalyserContexteSymbol(SyntaxNodeAnalysisContext context)
{
    if (context.Node is MethodDeclarationSyntax methode)
    {
        if (methode.Modifiers.Any(x => x.IsKind(SyntaxKind.AsyncKeyword)) 
            && methode.ReturnType.ToString() == "void")
        {
            var diagnostic = Diagnostic.Create(
                Règle,
                context.Node.GetLocation(),
                "Les méthodes asynchrones ne peuvent pas retourner void"
            );
            context.ReportDiagnostic(diagnostic);
        }
    }
}

  1. Utilisation de Roslyn pour la compilation dynamique de code

Une autre fonctionnalité importante de Roslyn est la compilation dynamique. Roslyn n'est pas seulement une plateforme de compilation, elle offre également des capacités puissantes de compilation et d'exécution de code dynamique, très utiles dans la construction de systèmes middleware et backend extensibles.

Prenons un exemple typique : nous avons une plateforme de fonctionnalités génériques de base (comme les processus d'approbation, la validation des données, la génération de rapports), et plusieurs systèmes métier basés sur cette plateforme. Bien que la logique de base soit générique, chaque partie métier peut avoir besoin d'insérer une logique personnalisée dans le processus standard, comme la modification des données avant ou après l'exécution d'une méthode, l'enregistrement de journaux, l'appel de services spécifiques, etc.

L'approche traditionnelle consiste à utiliser des interfaces + mode plugin ou injection de dépendances, mais cela nécessite de déterminer la classe d'implémentation lors de la compilation, ce qui manque de flexibilité. Grâce aux capacités de compilation dynamique de Roslyn, nous permettons aux développeurs métier d'écrire leur logique d'extension sous forme de script, qui est compilée et exécutée dynamiquement à l'exécution, réalisant une véritable extension à chaud.

Nous définissons un processus de traitement de données générique, qui permet à la partie métier de fournir un script C# à des points clés :

var script = @"parameters.Value += 1;";
var action = RoslynScriptRunner.CreateScript(script);
AA aA1 = new AA();
aA1.Value = 99;
action.Invoke(aA1);
Console.WriteLine(aA1.Value); // Affiche 100

Dans cet exemple :

  • AA est notre objet de contexte de données convenu.
  • script est le script d'expression C# fourni par la partie métier, représentant l'ajout de 1 à Value.
  • RoslynScriptRunner est une classe utilitaire qui encapsule la logique de compilation et d'exécution de Roslyn.
  • À l'exécution, la plateforme compile dynamiquement ce script et exécute l'objet métier aA1 comme paramètre.

Cela permet à différents systèmes métier d'injecter leur logique sans modifier le code du processus principal, réalisant une véritable extension à l'exécution.

Ce modèle est particulièrement adapté aux :

  • Règles métier qui changent fréquemment
  • Personnalisation dans des systèmes multi-locataires
  • Ouverture de capacités de développement secondaire dans des produits en plateforme

Grâce à Roslyn, nous traitons le "code" comme une "configuration", améliorant la flexibilité du système. Nous aborderons dans un article ultérieur d'autres scénarios d'utilisation et utilisations avancées de Roslyn, y compris mais sans s'y limiter, l'implémentation de la compilation et de l'exécution dynamiques et les générateurs de source.

Étiquettes: Roslyn C# compilation analyse statique génération de code

Publié le 5 juin à 23h28