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

image Dans le précédent billet, nous en étions restés à essayer de compiler le code généré par Razor afin de récupérer une assembly, mais le processus échouait. Ce billet (un peu plus costaud) a pour but de vous présenter les éléments qu’il faut impérativement paramétrer pour que la compilation de l’assembly fonctionne. Ainsi, après avoir lu ce billet, vous comprendrez beaucoup mieux le lien entre Razor et ASP.NET et comment le personnaliser.

Un moteur de templating avec un parseur personnalisé

Le besoin d’une classe de base

Pour rappel, voici les deux éléments empêchant le processus de compilation de se terminer avec succès :

  • La méthode générée Execute possède le qualificatif 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.

La résolution des deux points passe nécessairement par la création d’une classe de base qu’il faut appliquer à la classe générée __CompiledTemplate. Cette classe, que nous marquerons abstraite, doit contenir une méthode Execute, elle aussi abstraite. Le cas de la méthode WriteLiteral est un peu plus complexe, et, pour le comprendre, il nous faut prendre un peu de recul …

Le but d’un moteur de template est de prendre un ou plusieurs template en entrée, de les composer (les imbriquer), de les lier à un modèle de données afin de générer une sortie formatée. Le type de cette sortie peut prendre différentes formes :

  • Un document ;
  • Une page web ;
  • Du code source (lorsque vous utilisez T4 par exemple) ;

Dans le cas d’une application ASP.NET MVC, c’est une page web qui est produite par Razor. Techniquement, la classe générée par un template dérive d’une classe un peu particulière. En effet, cette classe est fortement liée à un flux d’écriture qui sert à générer la réponse HTTP retournée à l’utilisateur afin d’afficher une page web.

L’écriture dans ce flux se fait au travers des méthodes WriteLiteral, Write, etc. dont les appels ont été générés par Razor. Pour créer une classe de base qui génère un document en sortie, il suffit de passer une référence vers un flux particulier et d’implémenter ces méthodes de la bonne manière.

Notre classe de base pour les templates

Dans notre application d’exemple, nous allons faire le choix d’utiliser la console comme sortie pour l’exécution de notre template.

    public abstract class ConsoleViewBase
    {
      public abstract void Execute();

      protected virtual void WriteLiteral(string literal)
      {
        Console.Write(literal);
      }
    }
    

Pour que la génération de code utilise la classe ConsoleViewBase comme classe de base, il suffit simplement d’indiquer le nom du type en question au travers de la propriété DefaultBaseClass de la classe RazorEngineHost. Vous pouvez remarquer qu’étonnamment, il s’agit d’une chaîne de caractère et non d’un type que nous utilisons pour spécifier la classe de base. En effet, lors de la génération, Razor se contente simplement de recopier le nom de la classe qu’il a reçu en paramètre derrière le nom de la classe qu’il génère. Ainsi, vous pouvez faire le choix de lui passer directement le nom complet de la classe (c’est-à-dire, le nom de classe préfixé de l’espace de nom qui la contient), ce qui permet alors de se passer de la directive using correspondante. Dans l’exemple ci-dessous, je fais le choix de spécifier simplement le nom de la classe, sans son espace de nom.

    var razorEngineHost = new RazorEngineHost(new CSharpRazorCodeLanguage())
    {
      DefaultBaseClass = typeof (ConsoleViewBase).Name
    };
    

CodeDOM a pour but de générer une nouvelle assembly contenant la classe générée par Razor. Or, si nous spécifions une classe de base pour cette classe générée, il faut également ajouter une référence entre l’assembly générée par Razor et celle contenant la classe de base.

Pour le projet d’exemple utilisé dans ce billet, la classe de base est présente directement dans l’assembly chargée d’exécuter tout le processus de compilation du template et de générer du code. Pour faire simple, je me contente de passer les assemblies chargées dans l’AppDomain courant comme références à ajouter à l’assembly générée dynamiquement. L’ajout de ces références se fait au travers de la classe CompilerParameters, utilisée directement par CodeDOM que nous avions laissée de côté dans le précédent billet. Celle-ci attend un tableau de chaînes de caractères correspondant aux emplacements des assemblies qu’elle doit référencer.

    compilerParameters.ReferencedAssemblies.AddRange(AppDomain.CurrentDomain
    .GetAssemblies()
    .Select(a => a.Location)
    .ToArray());
    

Une fois la référence ajoutée, il faut encore ajouter l’espace de nom contenant notre classe ConsoleViewBase pour que celle-ci puisse être utilisée comme classe de base de la classe générée par Razor.

L’ajout d’espaces de noms dans le code généré peut se fait au travers de la propriété NamespaceImports. Dans l’exemple de code ci-dessous, j’y ajoute l’espace de nom utilisé dans mon application (celui que contient la classe ConsoleViewBase).

    var razorEngineHost = new RazorEngineHost(new CSharpRazorCodeLanguage())
    {
      DefaultBaseClass = typeof (ConsoleViewBase).Name
    };

    razorEngineHost.NamespaceImports.Add("ConsoleApplication28");
    

Souvenez-vous du fichier de configuration présenté au tout début du précédent billet. Celui-ci est utilisé par ASP.NET MVC pour configurer Razor. Notez maintenant qu’on y trouve deux éléments importants et avec lesquels vous pouvez d’ores et déjà faire un lien :

  • L’attribut pageBaseType, qui permet de spécifier la classe dont les vues générées par Razor doivent utiliser comme classe de base ;
  • Les différents espaces de noms à ajouter à la collection NamespaceImports.
    <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 est alors possible de relancer le programme afin de re générer l’assembly en sortie de Razor. Le résultat est visible sur la capture d’écran suivante.

image

Un type pour notre modèle

La compilation se termine toujours sur une erreur. Cette fois ci, elle n’est pas directement issue du paramétrage global de l’appel à la classe RazorTemplateEngine mais plutôt du contenu du template. En effet, c’est l’appel @Model.IsOk, présent dans notre template qui semble être la cause de l’erreur …

Nous l’avions dit dans le premier billet de cette série, par défaut, Razor considère tous les appels préfixés du symbole @ (sauf quelques cas particulier de certains éléments de syntaxe enregistrés auprès du parseur, nous y reviendrons), comme du code qu’il recopie alors directement dans le code généré en sortie.

Notre appel @Model.IsOk se retrouve sous la forme d’un appel de la propriété IsOk, sur une propriété Model qui doit être exposée par la classe de base utilisée par le template. En ajoutant cette propriété sur la classe de base, nous ferons un pas de plus vers le succès de la compilation de l’assembly générée. Or, nous pouvons nous interroger sur le type à utiliser pour cette propriété Model :

  • Il ne peut pas s’agir d’un simple type object, puisque nous devons pouvoir appeler une propriété IsOk définie sur ce type ;
  • Il ne peut pas s’agir d’un dynamic car cela rendrait l’utilisation de helper sur notre modèle beaucoup plus difficile.

La seule solution viable semble être de réussir à typer notre classe ConsoleViewBase avec un argument générique TModel et d’y exposer une propriété de type TModel. Nous allons maintenant voir comment implémenter un tel scénario.

Les vues typées dans ASP.NET MVC

Commençons par observer une utilisation basique de la propriété Model dans un template de vue ASP.NET MVC. Ci-dessous, je cite une des vues utilisées dans le modèle de projet ASP.NET MVC Application Internet. Les différents HtmlHelpers utilisés semblent automatiquement typés avec la classe RegisterModel.

    @model MvcApplication35.Models.RegisterModel

    @{
      ViewBag.Title = "Register";
    }

    <hgroup>
      <h1>@ViewBag.Title.</h1>
      <h2>Create a new account.</h2>
    </hgroup>

    @using (Html.BeginForm()) {
      @Html.AntiForgeryToken()
      @Html.ValidationSummary()

      <fieldset>
        <legend>Registration Form</legend>

        <ol>
          <li>
          @Html.LabelFor(m => m.UserName)
          @Html.TextBoxFor(m => m.UserName)
          </li>
          <li>
          @Html.LabelFor(m => m.Password)
          @Html.PasswordFor(m => m.Password)
          </li>

          <li>
          @Html.LabelFor(m => m.ConfirmPassword)
          @Html.PasswordFor(m => m.ConfirmPassword)
          </li>
        </ol>

        <input type="submit" value="Register" />
      </fieldset>
    }
    

En arrière plan, la classe générée par Razor possède la signature ci dessous. Les classes générées par Razor se situent dans le dossier temporaire d’ASP.NET, plus précisément dans le dossier du site concerné. Ce chemin varie selon l’installation du poste et l’utilisation d’un serveur IIS classique ou de IIS Express. Dans mon cas, il se trouve à l’emplacement C:\Windows\Microsoft.NET\Framework64\v4.0.30319\Temporary ASP.NET Files.

    public class _Page_Views_Account__Register_cshtml :
    System.Web.Mvc.WebViewPage<RegisterModel> {
    }
    

Vous l’aurez compris, c’est le mot clé @model qui est au cœur de ce mécanisme. Plutôt que de générer une classe dérivant simplement d’une classe WebViewPage (qui est pourtant celle paramétrée dans le fichier de configuration), il génère en fait une classe dérivant de WebViewPage<T>. T étant un argument défini par le nom de type passé avec l’instruction @model. Si vous faites le test sur une vue qui ne possède pas d’instruction @model, vous pourrez constater qu’à nouveau, la classe générée ne dérive pas de WebViewPage mais de WebViewPage<dynamic>.

Anatomie de l’instruction@model

Souvenez-vous, nous avons vu que dans sa configuration de Razor, ASP.NET MVC utilise une implémentation particulière de la classe RazorEngineHost, la classe MvcWebPageRazorHost, servie par la classe MvcWebRazorHostFactory (le type déclaré dans le fichier de configuration XML). L’usage d’une implémentation spécifique de la classe RazorEngineHost a en fait pour but de remplacer le parseur de code (C# ou VB) utilisé nativement par Razor, par un parseur enrichi.

Ce parseur personnalisé, correspondant au type MvcCSharpRazorCodeParser a pour but de reconnaître la directive @model dans un template. Dès lors qu’il la reconnaît, il doit ensuite écrire dans le code généré le nom de la classe de base à utiliser.

Je vais consacrer la suite de ce second billet à la personnalisation de Razor pour pouvoir supporter l’usage d’une directive @model dans un template, hors d’un contexte ASP.NET MVC. Nous allons voir que Razor a été conçu sur un modèle totalement extensible où il est possible de dériver et de modifier le comportement d’une majorité de composants.

Commençons par créer notre propre implémentation de la classe RazorEngineHost. Le but étant ici de la lier fortement à un parseur de code spécifique, nous pouvons surcharger sa méthode DecorateCodeParser pour y retourner notre implémentation personnalisée. Pour simplifier l’exemple ici, je mets volontairement de côté le support de Visual Basic dans l’écriture de template et ne donne la marche à suivre que pour C#. Vous n’aurez aucun mal à le porter pour VB ou lui faire supporter les deux langages, selon vos besoins.

    public class CustomCSharpRazorEngineHost : RazorEngineHost
    {
      public CustomCSharpRazorEngineHost()
        : base(new CSharpRazorCodeLanguage())
      {
      }

      public override ParserBase DecorateCodeParser(ParserBase incomingCodeParser)
      {
        return new CustomCSharpCodeParser();
      }
    }
    

La deuxième étape consiste à implémenter la classe CustomCSharpCodeParser. Nous pouvons le faire en créant simplement une classe qui dérive de la classe CSharpCodeParser. Cette dernière possède plusieurs méthodes protected intéressantes que nous pouvons utiliser. Elles vont nous simplifier grandement la tâche de parsing et de génération de code en masquant la complexité naturelle de ces deux problématiques.

La première de ces méthodes est la méthode MapDirective. Elle nous permet d’associer un mot clé à une action. Cela signifie que le parseur, en parcourant le code du template, dès qu’il rencontre un mot clé configuré, exécute alors l’action qui lui est associée. Nous allons utiliser cette méthode pour associer la lecture du mot clé model à une action de génération de code.

La seconde méthode est la méthode AcceptAndMoveNext. Elle permet d’ avancer le curseur du parseur jusqu’au prochain élément dans le template. Nous allons l’utiliser pour avancer le curseur du parseur du mot clé model jusqu’au type qui le suit.

Récapitulons : les deux méthodes que nous venons de citer nous permettent de repérer l’instruction @model dans un template et de déplacer le curseur sur le nom du type qui suit cette instruction. Il ne nous reste plus qu’à lire ce type et à l’écrire dans le code généré en sortie. Nous allons utiliser une troisième méthode pour effectuer ce traitement, la méthode BaseTypeDirective. Comme son nom l’indique, c’est elle qui est chargée d’émettre la directive qui spécifie le type de base à utiliser par la classe générée. En interne, elle lit en fait tout le contenu jusqu’au bout de la ligne courante dans le template. Si elle ne trouve pas de type, elle lève une erreur (le message d’erreur lié a cette erreur est un argument obligatoire lors de l’appel à la méthode BaseTypeDirective), et si elle trouve un type, elle instance une classe dédiée à la génération de code en lui passant ce qu’elle vient de lire dans le template. Cette instanciation se fait au travers d’une petite factory, il est possible de personnaliser la classe à utiliser, du moment que celle ci hérite de la classe SpanCodeGenerator.

En ce qui concerne la génération de code, Razor expose un grand nombre de types héritant pour la plupart de la classe SpanCodeGenerator. L’un de ces types est représenté par la classe SetBaseTypeCodeGenerator. Il contient toute la logique nécessaire pour générer l’instruction d’héritage d’une classe. Dans son fonctionnement, le nom du type à utiliser comme classe de base doit être retourné par sa méthode virtuelle ResolveType. Malheureusement, la classe SetBaseTypeCodeGenerator ne gère par les classes génériques. Nous allons l’étendre pour gérer ce cas de figure.

Dans l’implémentation suivante, le type baseType correspond au type lu depuis le template derrière l’instruction model. Il est celui qui doit être utilisé en tant qu’argument générique. Pour obtenir le nom de la classe à générer, nous pouvons passer par l’instance de la classe CodeGeneratorContext. Une fois les deux noms en notre possession, nous n’avons plus qu’à les combiner.

    public class CustomSetBaseTypeCodeGenerator : SetBaseTypeCodeGenerator
    {
      public CustomSetBaseTypeCodeGenerator(string baseType) : base(baseType)
      {
      }

      protected override string ResolveType(CodeGeneratorContext context, string baseType)
      {
        return string.Format("{0}<{1}>", context.Host.DefaultBaseClass, baseType);
      }
    }
    

Nous pouvons maintenant écrire notre parseur qui définit la directive model en utilisant les différentes méthodes citées plus haut.

    public class CustomCSharpCodeParser : CSharpCodeParser
    {
      public CustomCSharpCodeParser()
      {
      MapDirectives(() =>
        {
          AcceptAndMoveNext();

          BaseTypeDirective("Le mot clé model doit être suffixé d'un type", baseType => new CustomSetBaseTypeCodeGenerator(baseType));
        }, "model");
      }
    }
    

Voilà, il ne reste plus qu’à changer la ligne chargée d’instancier notre RazorHostFactory afin d’utiliser notre version personnalisée. Dès lors, tous les template seront automatiquement traités avec notre parseur fait maison.

    var razorEngineHost = new CustomCSharpRazorEngineHost()
    {
      DefaultBaseClass = typeof (ConsoleViewBase).Name
    };
    

Imaginons le modèle suivant.

    public class MonModele
    {
      public bool IsOk { get; set; }
    }
    

Nous pouvons aisément ajouter l’instruction @model dans notre template.

    @model ConsoleApplication28.MonModele

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

Si nous exécutons à nouveau l’application, nous constatons que le code généré par Razor contient bien une classe qui dérive d’une version générique de classe ConsoleViewBase. Or, cette version générique de la classe, nous ne l’avons pas encore créée… L’assembly ne peut toujours pas être générée.

image

Notez que cette modification, en l’état, vous oblige à typer les template, c’est-à-dire, à y inclure une instruction @model. Ce billet étant déjà très long, je vous laisse gérer le cas où, si aucune instruction @model n’est trouvée, alors la classe générée doit dérivée d’une classe dont l’argument générique est un dynamic (quelque chose comme ConsoleViewBase<dynamic> par exemple).

La première exécution de notre template

Avant de nous attaquer à la classe de base, je veux avancer un peu sur l’instanciation et l’exécution du template, notamment pour vous montrer dès à présent un élément extrêmement important.

Une fois que CodeDOM a compilé l’assembly, nous obtenons un objet de type CompilerResults. Ce même objet contient une propriété CompiledAssembly, au nom assez explicite. Pour pouvoir exécuter notre template et produire un résultat, en l’occurrence afficher quelque chose dans la console, nous devons commencer par instancier le type généré par notre template.

La reflection nous permet de retrouver facilement un type dans une assembly … A condition que l’on possède son nom et son espace de nom bien évidemment. S’agissant de code généré, on pourrait s’interroger sur les valeurs utilisées par Razor pour ces deux éléments. Si vous jetez un coup d’œil au code généré, vous trouverez que la classe s’appelle @__CompiledTemplate et l’espace de nom Razor. Plutôt que de mettre ces valeurs en dur dans le code, vous pouvez utiliser les propriétés DefaultNamespace et DefaultClassName présentes sur une instance de la classe RazorEngineHost. Ces deux propriétés peuvent également être utilisées pour personnaliser les valeurs par défaut.

Ainsi, l’exemple de code suivant permet de récupérer le type généré par notre template Razor.

    var type = compilerResults.CompiledAssembly.GetType(string.Format("{0}.{1}", razorEngineHost.DefaultNamespace, razorEngineHost.DefaultClassName));
    

Une fois le bon type en main, les étapes restantes sont les suivantes :

  1. Instancier ce type
  2. Définir le modèle à utiliser par l’instance
  3. Appeler la méthode Execute et regarder le résultat qui s’affiche dans la console

Pas de problème majeur pour la première étape, nous faisons un simple appel à Activator.CreateInstance et nous obtenons l’instance de la classe générée. Pour pouvoir réaliser la troisième étape, nous allons avoir besoin de caster l’instance en un type qui possède la méthode Execute et implémenté par la classe générée. Deux choix s’offrent à nous : ConsoleViewBase et ConsoleViewBase<TModel>. Pour que notre code reste totalement générique jusqu’au bout, nous allons porter notre choix sur la classe ConsoleViewBase. Choisir ConsoleViewBase<TModel> nous obligerait à fixer un type en dur dans notre code, ce n’est pas ce que nous voulons.

    // 1...
    var instance = Activator.CreateInstance(type) as ConsoleViewBase;

    // 2...
    // ???

    // 3...
    instance.Execute();
    

Il ne reste que le problème de la deuxième étape. Il nous faut une méthode, présente sur la classe ConsoleViewBase et pas uniquement sur ConsoleViewBase<TModel> qui doit nous permettre de définir le modèle. L’instance de ce modèle doit ensuite pouvoir être utilisée dans le code généré par le template directement via son type TModel et pas par un cast d’un type object.

Nous y sommes presque !

Une classe de base typée

Pour implémenter notre classe de base typée, nous allons commencer par créer un conteneur pour notre modèle. Cette astuce nous permet de faire le pont facilement entre une instance typée de modèle et une instance non typée.

Créons une classe ModelContainer chargée de contenir le modèle sous la forme non typée. Nous y plaçons un constructeur classique qui prend notre modèle, mais également un constructeur de copie.

    public abstract class ModelContainer
    {
      private readonly object _model;

      protected ModelContainer(object model)
      {
        _model = model;
      }

      protected ModelContainer(ModelContainer modelContainer)
      {
        _model = modelContainer.Model;
      }

      public object Model
      {
        get
        {
          return _model;
        }
      }
    }
    

Dans un deuxième temps, ajoutons une classe ModelContainer<TModel>, une variante typée de la classe ModelContainer. Comme dans l’implémentation de ModelContainer, nous y plaçons deux constructeurs et une propriété Model (celle-ci masque la version non typée de la propriété). Notez bien que le constructeur de copie reçoit en paramètre une instance de la classe ModelContainer et pas ModelContainer<TModel>.

    public class ModelContainer<TModel> : ModelContainer
    {
      public ModelContainer(TModel model)
        : base(model)
      {
      }

      public ModelContainer(ModelContainer modelContainer)
        : base(modelContainer)
      {
      }

      public new TModel Model
      {
        get { return (TModel)base.Model; }
      }
    }
    

Retournons à présent sur la classe ConsoleViewBase et ajoutons-y une méthode abstraite SetModel qui prend un argument de type ModelContainer (le tout est non typé). C’est cette méthode que nous pourrons appeler après avoir instancié la classe générée.


    public abstract class ConsoleViewBase
    {
      public abstract void SetModel(ModelContainer modelContainer);

      public abstract void Execute();

      protected virtual void WriteLiteral(string literal)
      {
        Console.Write(literal);
      }
    }
    

Il est temps d’ajouter une classe ConsoleViewBase<TModel> et d’y placer un champ privé de type ModelContainer<TModel>. Surchargez la méthode SetModel qui reçoit un ModelContainer non typé. Utilisez le constructeur de copie de classe ModelContainer<TModel> afin de transformer l’instance non typée en une instance typée. Dernier point, ajoutez une propriété Model de type TModel, c’est elle qui est utilisée directement à l’intérieur du template.

    public abstract class ConsoleViewBase<TModel> : ConsoleViewBase
    {
      private ModelContainer<TModel> _modelContainer;

      public override void SetModel(ModelContainer modelContainer)
      {
        _modelContainer = new ModelContainer<TModel>(modelContainer);
      }

      public TModel Model
      {
        get { return _modelContainer.Model; }
      }
    }

    

La (vraie) première exécution de notre template

Maintenant que la bonne classe de base est créée, il ne reste plus qu’à compléter le code de l’instanciation du template dont nous avons parlé un peu plus tôt. Faites simplement un appel à SetModel en lui passant une instance de ModelContainer. Notez que l’équivalent de notre ModelContainer dans une application ASP.NET MVC, c’est tout simplement la classe ViewData.

    // 1...
    var instance = Activator.CreateInstance(type) as ConsoleViewBase;

    // 2...
    instance.SetModel(new ModelContainer<MonModele>(new MonModele() { IsOk = true }));

    // 3...
    instance.Execute();
    

image

En faisant varier le booléen présent dans le modèle, nous obtenons bien une sortie différente dans la console.

image

C’en est tout pour aujourd’hui. Nous avons réussi à faire usage du moteur Razor en combinant des modèles utilisables dans nos template et en garantissant la généricité de notre traitement. J’espère que cela vous a permis de découvrir et de bien comprendre les différents éléments exposés dans la section de configuration de Razor que vous pouvez trouver dans un projet ASP.NET. J’espère également que vous êtes conscients de la capacité d’extension de Razor ! ASP.NET MVC est déjà un framework qui offre de nombreux points d’entrée à la personnalisation, ce moteur de vue l’est tout autant, sinon plus. Dans les prochains billets, nous allons nous recentrer un peu plus sur la personnalisation de Razor dans un contexte ASP.NET MVC.

A bientôt,

Nombre de vue : 786

COMMENTAIRES 1 commentaire

  1. binardfr dit :

    Super intéressant de découvrir les rouages de Razor. Très bon article, vivement la suite !

AJOUTER UN COMMENTAIRE