Accueil Nos publications Blog [ASP.NET MVC] Ces petites choses de Razor que l’on ignore … (1/4)

[ASP.NET MVC] Ces petites choses de Razor que l’on ignore … (1/4)

image Ce premier billet entame une série consacrée à Razor. A la fin de la lecture de cette série, le développeur ASP.NET MVC saura comment fonctionne ce moteur de vue en interne, comment le configurer, comment l’étendre, et découvrira des éléments de syntaxe trop rarement utilisés. L’objectif de ce premier article est de comprendre un peu mieux comment Razor est utilisé au sein d’ASP.NET MVC afin, dans un second temps, d’être capable de mieux l’étendre. En fait, nous allons découvrir que Razor est un moteur qui peut être utilisé dans d’autres circonstances qu’un projet ASP.NET MVC…

Du template au code source d’une vue

Razor et ASP.NET MVC

Commençons par ouvrir un projet Web et à regarder le fichier web.config se trouvant le répertoire Views du projet. Avec ASP.NET MVC 5, ce fichier contient notamment les lignes suivantes.


        <system.web.webPages.razor>
          <host factoryType="System.Web.Mvc.MvcWebRazorHostFactory, System.Web.Mvc, Version=5.0.0.0, Culture=neutral, PublicKeyToken=31BF3856AD364E35"
        />
          <pages pageBaseType="System.Web.Mvc.WebViewPage">
            <namespaces>
              <add namespace="System.Web.Mvc" />
              <add namespace="System.Web.Mvc.Ajax" />
              <add namespace="System.Web.Mvc.Html" />
              <add namespace="System.Web.Optimization"/>
              <add namespace="System.Web.Routing" />
              <add namespace="WebApplication6" />
            </namespaces>
          </pages>

        </system.web.webPages.razor>
        

Il s’agit en fait de la configuration nécessaire au bon fonctionnement de Razor dans le cadre d’un projet ASP.NET MVC. On peut notamment remarquer la présence de classes dans l’espace de nom System.Web.Mvc. Pour mémoire, Razor a été publié pour la première fois pour ASP.NET MVC 3 en même temps que pour ASP.NET WebPages 2. Historiquement il est donc utilisable avec ASP.NET WebPages en plus d’ASP.NET MVC. Nous pouvons donc déduire que ce sont les classes déclarées dans ce fichier de configuration qui font la jonction entre Razor et la technologie qui doit l’utiliser. En créant nos propres classes ou en utilisant celles de base éventuellement livrées avec Razor, nous serons donc capables de l’utiliser quel que soit le contexte.

Après avoir créé un nouveau projet, de type application console par exemple, on peut alors ajouter le paquet Nuget nommé Microsoft.AspNet.Razor. Cette action ajoute uniquement une référence vers l’assembly System.Web.Razor. Aucune référence à System.Web.Mvc ni de System.Web.WebPages.* n’est alors ajoutée !

image

Razor, un moteur de templating

Razor est un moteur de composition, c’est-à-dire, un moteur de templating. Dès lors, la classe qui nous permet de générer une sortie à partir d’un template s’appelle RazorTemplateEngine. C’est le point d’entrée principale de Razor.

Or, pour pouvoir instancier un objet de type RazorTemplateEngine, il est nécessaire de lui passer une instance de RazorEngineHost. Si vous avez bien suivi jusque-là, ce nom devrait vous dire quelque chose. En effet, souvenez-vous, dans la configuration de l’application ASP.NET MVC vu au début du billet, l’un des types déclaré s’appelle MvcWebRazorHostFactory (issu de l’assemblage System.Web.Mvc). Dans le cadre d’une application ASP.NET MVC, c’est donc ce type qui est utilisé pour créer une instance de RazorEngineHost qui sera ensuite passé à la classe RazorTemplateEngine.

Avant de continuer, et pour bien comprendre la suite, il est nécessaire de s’attarder sur la manière dont Razor lit et comprend un template qu’il reçoit en paramètre. Pour la lecture, Razor s’appuie sur deux éléments : un parseur de markup (qui est capable de reconnaître des balises HTML) et un parseur de code (qui est capable de reconnaître du code C# ou du code VB, selon la configuration choisie).

A la réception d’un document, le traitement est le suivant :

  1. C’est le parseur de markup qui commence le travail.
  2. Le parseur de markup lit le plus de caractères possible jusqu’à ce qu’il rencontre du contenu qu’il juge comme étant du code. Ce jugement est simplement basé sur la présence d’un caractère @, utilisé en dehors du contexte d’une adresse email. Si le parseur trouve du code, il laisse alors la main au parseur de code.
  3. De la même manière, le parseur de code va avancer dans le document jusqu’à ce qu’il rencontre du contenu qu’il juge comme étant du markup (des balises) au sein de sa portée. Lorsqu’il en trouve, il rend la main au parseur de markup.
  4. On repart alors à l’étape 2.

Ainsi, on peut voir l’enchaînement d’interventions des deux parseurs lors de la lecture d’un template comme un arbre (cf. le schéma suivant).

image

Sachant cela, on comprend d’ailleurs mieux le besoin d’utiliser le couple de balise <text> et </text> lorsque l’on souhaite émettre du contenu sans balisage au milieu d’une condition de code C# ou VB (ce contenu pouvant être du texte directement ou du code JavaScript par exemple).


    <script type="text/javascript">
       var data = [];

       @foreach (var r in Model.rows)
       {
          <text>
                data.push(r.Value);
          </text>
       }
    </script>
    

Dernière chose à savoir avant de passer à la suite. Après avoir parsé un template, le moteur Razor génère en fait du code via CodeDOM. On en parlera plus en détails dans la suite de ce billet, mais, grossièrement, il génère des écritures sur une sortie pour tout le contenu markup qu’il rencontre et il et recopie le contenu lu par le parseur de code. CodeDOM aidant, le code lu et le code recopié peuvent être de langages différents : ainsi, nous pouvons avoir un template écrit en Visual Basic et générer du code C#, et inversement.

Pour pouvoir instancier la classe RazorEngineHost, nous devons lui spécifier le type de parseur de code à utiliser. L’exemple suivant représente l’instanciation dans le cas où le template reçu en paramètre est écrit en C#.


    var razorEngineHost = new RazorEngineHost(new CSharpRazorCodeLanguage());
    var razorTemplateEngine = new RazorTemplateEngine(razorEngineHost);
    

L’alternative suivante présente le cas où le langage à utiliser pour la génération de code est Visual Basic.


    var razorEngineHost = new RazorEngineHost(new VBRazorCodeLanguage());
    var razorTemplateEngine = new RazorTemplateEngine(razorEngineHost);
    

La classe MvcWebRazorHostFactory évoquée plus tôt et utilisée dans le cadre d’une application ASP.NET MVC instancie en fait une implémentation particulière de la classe RazorEngineHost capable de sélectionner un parseur de code automatiquement selon l’extension de fichier du template qui lui est passé. Ainsi, si elle reçoit un template d’extension .cshtml, elle choisit la classe CSharpRazorCodeLanguage. A l’inverse, si elle reçoit un template d’extension .vbhtml, alors elle choisit la classe VBRazorCodeLanguage.

La classe RazorTemplateEngine possède deux méthodes intéressantes :

  • ParseTemplate, qui permet de valider que la syntaxe utilisée dans un template est correcte ;
  • GenerateCode, qui permet d’obtenir l’arbre CodeDOM généré pour le template reçu en argument, mais permet également d’accéder aux éventuelles erreurs de validation de la syntaxe.

C’est cette seconde méthode que nous allons utiliser dans la suite de ce billet. Pour la démo de ce billet, je pars du principe que le template utilisé est présent dans le projet et est automatiquement copié dans le répertoire de sortie du projet (cf. la fenêtre de propriété ci-dessous).

image

Et voici le template que je vais utiliser pour la suite.


    @if(Model.IsOk)
    {
      <ok />
    }
    else
    {
      <nok />
    }
    

La méthode GenerateCode retourne une instance de la classe GeneratorResults. Cette dernière contient un booléen qui nous indique rapidement le résultat de la génération. Si la valeur de celui-ci est à false, nous pouvons consulter la liste des erreurs de validation.

Dans l’extrait de code ci-dessous, je lis le contenu de mon template, et si la génération échoue, j’affiche le détail des erreurs.



    GeneratorResults generatorResults;

    using (var file = File.Open("template.xml", FileMode.Open, FileAccess.Read))
    {
        using (TextReader textReader = new StreamReader(file))
        {
            generatorResults = razorTemplateEngine.GenerateCode(textReader);
        }
    }

    if (generatorResults == null)
        throw new InvalidOperationException();

    if (!generatorResults.Success)
    {
        foreach (var parserError in generatorResults.ParserErrors)
        {
            Console.WriteLine(parserError.Message);
        }

        return;
    }
    

Notez que mon template d’exemple présenté un peu plus haut est écrit en C#. Ainsi, si je paramètre mon instance de RazorEngineHost avec un parseur de VB et que je lance le programme tel que nous venons de l’écrire, j’ai la sortie suivante.

image

Assurez-vous toujours que le parser instancié correspond bien au langage utilisé pour écrire les template.

Razor et la génération de code

L’arbre CodeDOM généré est accessible au travers de la propriété GeneratedCode de la classe GeneratorResults. Si vous ne connaissez pas CodeDOM, sachez qu’il s’agit d’une technologie de génération de code présente dans le Framework .NET depuis la version 2.0. Elle est largement utilisée par ASP.NET pour la compilation des contrôles, mais aussi par le designeur WinForms. Elle est capable de générer du code dans différents langages, selon le provider utilisé, à partir d’un même arbre. L’inconvénient étant que la syntaxe de CodeDOM est assez lourde et complexe à utiliser. Pour plus d’informations sur le sujet, un petit tour sur la MSDN : https://msdn.microsoft.com/fr-fr/library/y2k85ax6(v=vs.110).aspx.

Deux options s’offrent maintenant à nous :

  • Utiliser CodeDOM pour générer le code source correspondant à la sortie de Razor. Cela est très utile pour débogguer et visualiser précisément le travail du parseur.
  • Utiliser CodeDOM pour générer une assembly afin de pouvoir ensuite exécuter la classe générée par Razor.

Nous allons bien évidemment explorer les deux voies… Commençons par la génération du code.

Pour utiliser CodeDOM, nous allons avoir besoin d’un provider. Celui-ci est propre à un langage (C# ou VB par exemple), et s’obtient via la méthode statique CreateProvider de la classe CodeDomProvider. La méthode attend le nom du langage cible sous la forme d’une chaine de caractère.

Si vous vous souvenez bien, plus tôt dans ce billet, nous avons parlé de parseur de code interne à Razor. Ce parseur, nous l’initialisions et le passions au constructeur de la classe RazorEngineHost. Sachez que les parseurs de code de Razor héritent de la classe RazorCodeLanguage, qui possède une propriété LanguageName. Nous pouvons donc la réutiliser pour obtenir notre provider CodeDOM. Bien évidemment, nous aurions pu faire le choix de générer un provider indépendamment de la configuration utilisée pour Razor.

Pour générer du code, nous devrons donc appeler la classe GenerateCodeFromCompileUnit, comme le montre l’exemple ci-dessous. L’usage d’un StringBuilder permet d’afficher facilement dans la console le code généré.


    var codeDomProvider = CodeDomProvider.CreateProvider(razorEngineHost.CodeLanguage.LanguageName);

    var stringBuilder = new StringBuilder();

    using (var stringWriter = new StringWriter(stringBuilder))
    {
        codeDomProvider.GenerateCodeFromCompileUnit(generatorResults.GeneratedCode, stringWriter, new CodeGeneratorOptions());
    }

    Console.WriteLine(stringBuilder.ToString());
    

La capture ci-dessous représente l’état de ma console avec le code généré pour mon template. On y trouve ma condition, directement retranscrite en C#. Mon markup lui, a été passé sous la forme de paramètre à une méthode WriteLiteral.

image

Avec le même template et avec Razor configuré pour parser du code C#, je peux choisir d’utiliser un provider CodeDOM pour Visual Basic et j’obtiendrai alors la sortie suivante.

image

Si vous possédez un œil aguerri, vous avez probablement remarqué les éléments suivants :

  • Le code généré a été placé dans un namespace nommé Razor ;
  • Le nom de la classe générée est __CompiledTemplate ;
  • La méthode générée Execute l’est en tant que override, or, la classe __CompiledTemplate n’a pas de classe de base ;
  • Le code généré fait appel à une méthode WriteLiteral qui n’existe pas.

Au vu des points ci-dessous, nous pouvons d’ores et déjà conclure que le code généré en l’état ne compile pas. Pour en avoir le cœur net, nous pouvons avancer un peu dans le processus et demander à notre provider CodeDOM de nous générer une assembly.

Pour générer l’assembly, nous avons besoin d’appeler simplement la méthode CompileAssemblyFromDom en lui passant une instance de la classe CompilerParameters. Nous reviendrons sur cette dernière dans le prochain billet. En sortie de la méthode, nous obtenons une instance de CompilerResults, qui, sur le même principe que la classe GeneratorResults vu précédemment, nous permet de savoir si le processus est en échec ou non.

L’extrait de code ci-dessous fait appel à la méthode CompileAssemblyFromDom et affiche les éventuelles erreurs dans la console.


    var compilerParameters = new CompilerParameters();

    var compilerResults = codeDomProvider.CompileAssemblyFromDom(compilerParameters, generatorResults.GeneratedCode);

    if (compilerResults.Errors.HasErrors)
    {
        foreach (var error in compilerResults.Errors)
        {
            Console.WriteLine(error);
        }

        return;
    }
    

En l’état, et par rapport aux éléments précédents, la sortie du programme est la suivante. Nous retrouvons donc notre erreur de compilation !

image

En fait, tout au long de cet article, nous sommes allés à l’essentiel. Certains des acteurs rencontrés (RazorEngineHost, le CompilerParameters de CodeDOM) acceptent des paramètres pour spécifier une classe de base, des assemblies à références, etc. La génération de l’assembly ne peut fonctionner qu’en configurant un minimum ces éléments ! Ce sera l’objet de notre prochain billet. Je vous donne donc rendez-vous dans quelques jours :-).