[ASP.NET] HTML ou JSON ? Action MVC ou Web API ? Recueil d’expériences (2/2)

ASP.NET logo

Second billet d’exploration des différentes techniques d’exposition et de consommation de données avec ASP.NET MVC et ASP.NET Web API. Ici, c’est le templating qui est à l’honneur. Encore une fois, l’approche se fait par un recueil de plusieurs techniques que j’ai pu mettre en place chez mes clients et sur les conclusions que j’ai pu tirer de chacune d’entre elles.

Au fait …

… si vous avez manqué la première partie, cela se passe ici.

Le templating côté client

A présent, je souhaite explorer une autre voie. Celle où mes actions ne me retournent plus une vue mais directement des données. En gros, cela correspond à tous les cas où je souhaite consommer des données depuis une action MVC utilisant un JsonResult ou alors les cas où je souhaite consommer des données depuis une action Web API.

Comment faire pour afficher ces données dans le navigateur Web de mon client ?

Tout d’abord, il y a la solution à l’ancienne … Celle dont on n’est jamais trop fier mais qui fait quand même plutôt bien son boulot. Mais pour ce qui est de la lisibilité, la maintenabilité et la facilité d’utilisation par les intégrateurs, on reviendra…

Côté contrôleur, les choses ne changent pas énormément. Si la page est un affichage complet dans le navigateur du client, je pousse la vue. Sinon, s’il s’agit d’une requête AJAX ou possèdent un en-tête Accept application/json, je lui pousse les données en JSON.

public class ProductController : Controller
{
    public ProductController()
    {
    }

    public IEnumerable<Product> GetProducts()
    {
        var random = new Random((int)DateTime.Now.Ticks);

        var start = random.Next(1000);
        var count = random.Next(5, 100);

        var ids = Enumerable.Range(start, count);

        foreach (var id in ids)
        {
            yield return new Product { Id = id };
        }
    }

    public ActionResult Index()
    {
        var products = GetProducts().ToList();

        if (Request.IsAjaxRequest() || Request.AcceptTypes.Contains("application/json"))
        {
            return Json(products, JsonRequestBehavior.AllowGet);
        }

        return View("Index", products);
    }
}

Et côté client.

@model IEnumerable<WebApplication5.Controllers.Product>

<div>
    <a href="@Url.Action("Index", "Product")" class="random">Random</a>
</div>

<div class="products">
    @Html.Partial("_Products", Model)
</div>

@section scripts {
    <script type="text/javascript">
        $("a.random").click(function (args) {
            args.preventDefault();

            $.getJSON(this.href, function (data) {
                var $ul = $("<ul></ul>");

                $.each(data, function (index, item) {
                    var $li = $("<li>Produit #" + item.Id + "</li>");
                    $li.appendTo($ul);
                });

                $(".products").html($ul);
            });
        });
    </script>
}

En gros, j’itère sur ma collection de données et je construis l’affichage de chaque élément à la volée. Puis, j’injecte le tout dans le DOM.

Comme précisé plus haut, cette solution comporte tout de même un nombre assez conséquent de désavantage. Pour rendre le tout plus propre, je peux utiliser un autre concept : le templating côté client. En gros, je décris ma vue, le gabarit à utiliser pour un item, en utilisant une syntaxe particulière, celle du moteur de templating client que j’ai sélectionné. Puis, pour chaque item que j’ai à afficher, j’applique le template. C’est plus propre, beaucoup plus lisible et même un peu plus performant.

Je ne vais pas rentrer dans les détails de la technique, j’en avais déjà parlé ici. La seule différence étant sur le choix du Framework de templating. Là où à l’époque j’avais utilisé JQuery Template, je préfère maintenant me tourner vers d’autres choix tels que Mustache ou Handlebars.

Ici, j’ai choisi d’utiliser Mustache (via un paquet Nuget) quand j’ai ajouté dans mon bundle JQuery. Voici le résultat de cette solution.

@model IEnumerable<WebApplication5.Controllers.Product>

<div>
    <a href="@Url.Action("Index", "Product")" class="random">Random</a>
</div>

<div class="products">
    @Html.Partial("_Products", Model)
</div>

@section scripts {
    <script id="productsTemplate" type="text/x-mustache-template">
        <ul>
            {{#.}}
            <li>Produit #{{Id}}</li>
            {{/.}}
        </ul>
    </script>

    <script type="text/javascript">
        $("a.random").click(function (args) {
            args.preventDefault();

            $.getJSON(this.href, function (data) {
                var $productsTemplate = $("#productsTemplate").html();
                var html = Mustache.render($productsTemplate, data);
                $(".products").html(html);
            });
        });
    </script>
}

Des templates, oui mais pour tout le monde

Le problème d’une solution basée sur du templating client en JavaScript, c’est que je me retrouve à écrire deux fois mes templates : une première fois pour le rendu serveur de mes éléments avec Razor (le fichier _Products.cshtml), et une seconde fois pour les rendus à faire après un appel AJAX, dans le tag script dans mon DOM.

Cela pose un double souci : pour les intégrateurs graphiques, il va falloir passer à deux endroits, sur les deux templates, pour rendre le tout joli. Pour nous les développeurs, et surtout pour ceux qui passeront faire de la TMA sur un projet, cela implique de savoir qu’il y a deux templates à modifier si des nouveaux champs sont ajoutés à posteriori. Bref, côté maintenabilité, la solution n’est pas au top.

En fait, pour résoudre ce souci lié à la maintenance de l’application, il suffit théoriquement de trouver un moyen de factoriser le template entre le serveur et le client. En fait, je cherche un moyen d’écrire un template une seule et unique fois, pouvoir l’exécuter sur mon serveur pour le premier rendu de la page, et pouvoir l’exécuter chez mon client pour les mises à jour de la page en AJAX.

Mon besoin est donc d’isoler un template pour :

  1. L’exécuter côté serveur en lui appliquant un modèle. La réponse HTTP retournée au client devra donc contenir directement le HTML du template transformé à partir d’un modèle.
  2. Pouvoir copier mon template tel quel dans le gabarit HTML de la page, sans lui avoir appliqué de modèle, afin de pouvoir l’utiliser en JavaScript.
  3. L’exécuter côté client en lui appliquant un modèle JSON.

Il me faut donc une technologie de templating capable de s’exécuter à la fois côté client et à la fois côté serveur. Mustache est à la base qu’une simple syntaxe de templating qui a ensuite été implémentée sur de nombreuses plateformes. Parmi ces plate-formes, on trouve bien sur JavaScript (c’est l’implémentation que nous utilisons côté client), PHP, mais aussi .NET.

Dans mon cas, je pourrais donc imaginer partager mon template entre mon client et mon serveur en l’exprimant dans une seule et même technologique : Mustache. Côté serveur, il me suffirait donc de créer moteur de vue ASP.NET MVC qui encapsule l’appel au parseur Mustache .NET et le tour est joué. Le parseur .NET en question que j’utilise est Nustache (https://github.com/jdiamond/Nustache).

Ci-dessous, une première classe qui me permet de gérer le chargement d’un template Mustache ; dans mon cas, il s’agit de fichier .mustache stocké sur le système de fichier, dans mon application Web.

internal class NustacheTemplateBuilder
{
    private readonly string _source;

    public NustacheTemplateBuilder(string source)
    {
        _source = source;
    }

    public string Source
    {
        get
        {
            return _source;
        }
    }

    public static NustacheTemplateBuilder FromFile(string filePath)
    {
        var templateSource = File.ReadAllText(filePath);

        return new NustacheTemplateBuilder(templateSource);
    }

    public Template ToTemplate()
    {
        var template = new Template();

        template.Load(new StringReader(_source));

        return template;
    }
}

L’implémentation d’un moteur de vue passe par deux éléments : implémenter l’interface IView et implémenter l’interface IViewEngine. Ci dessous, l’implémentation de la vue en elle même. C’est ici que je charge et met en cache le contenu de mon template, et que je fais appel à Nustache pour transformer le modèle reçu en paramètre avec le template ciblé.

public class NustacheView : IView
{
    private readonly NustacheViewEngine _engine;
    private readonly ControllerContext _controllerContext;
    private readonly string _viewPath;
    private readonly string _masterPath;

    public string ViewPath
    {
        get
        {
            return _viewPath;
        }
    }

    public NustacheView(NustacheViewEngine engine, ControllerContext controllerContext, string viewPath, string masterPath)
    {
        _engine = engine;
        _controllerContext = controllerContext;
        _viewPath = viewPath;
        _masterPath = masterPath;
    }

    public void Render(ViewContext viewContext, TextWriter writer)
    {
        var viewTemplate = GetTemplate();

        var data = viewContext.ViewData.Model;

        if (!string.IsNullOrEmpty(_masterPath))
        {
            var masterTemplate = LoadTemplate(_masterPath);

            masterTemplate.Render(data, writer, name =>
                {
                    if (name == "Body")
                    {
                        return GetTemplate();
                    }

                    var template = viewTemplate.GetTemplateDefinition(name);

                    if (template != null)
                    {
                        return template;
                    }

                    return FindPartial(name);
                });
        }
        else
        {
            GetTemplate().Render(data, writer, FindPartial);
        }
    }

    private Template GetTemplate()
    {
        return LoadTemplate(_viewPath);
    }

    private Template LoadTemplate(string path)
    {
        var key = "Nustache:" + path;

        if (_controllerContext.HttpContext.Cache[key] != null)
        {
            return (Template)_controllerContext.HttpContext.Cache[key];
        }

        NustacheTemplateBuilder nustacheTemplateBuilder;
        var templatePath = _controllerContext.HttpContext.Server.MapPath(path);

        if (_controllerContext.HttpContext.Cache[key + "_source"] != null)
        {
            nustacheTemplateBuilder = new NustacheTemplateBuilder(_controllerContext.HttpContext.Cache[key + "_source"].ToString());
        }
        else
        {
            nustacheTemplateBuilder = NustacheTemplateBuilder.FromFile(templatePath);

            _controllerContext.HttpContext.Cache.Insert(key + "_source", nustacheTemplateBuilder.Source, new CacheDependency(templatePath));
        }            

        var template = nustacheTemplateBuilder.ToTemplate();

        _controllerContext.HttpContext.Cache.Insert(key, template, new CacheDependency(templatePath));

        return template;
    }

    private Template FindPartial(string name)
    {
        var viewResult = _engine.FindPartialView(_controllerContext, name, false);

        if (viewResult != null)
        {
            if (viewResult.View == null)
            {
                var stringBuilder = new StringBuilder();

                foreach (var str in viewResult.SearchedLocations)
                {
                    stringBuilder.AppendLine();
                    stringBuilder.Append(str);
                }

                var msg = string.Format("The partial view '{0}' was not found or no view engine supports the searched locations. The following locations were searched:{1}", name, stringBuilder);

                throw new InvalidOperationException(msg);
            }

            var nustacheView = viewResult.View as NustacheView;

            if (nustacheView != null)
            {
                return nustacheView.GetTemplate();
            }
        }

        return null;
    }
}

Enfin, l’implémentation de l’interface IViewEngine. Je passe par un intermédiaire, la classe VirtualPathProviderViewEngine. Elle me simplifie tout le travail de localisation de mes vues (cf. mes articles sur Razor).

public class NustacheViewEngine : VirtualPathProviderViewEngine
{
    public NustacheViewEngine(string[] fileExtensions = null)
    {
        Encoders.HtmlEncode = HttpUtility.HtmlEncode;

        FileExtensions = fileExtensions ?? new[] { ".mustache" };
        ViewLocationFormats = new string[] { "~/Views/{1}/{0}.mustache", "~/Views/Shared/{0}.mustache" };
        MasterLocationFormats = new string[] { "~/Views/{1}/{0}.mustache", "~/Views/Shared/{0}.mustache" };
        PartialViewLocationFormats = new string[] { "~/Views/{1}/{0}.mustache", "~/Views/Shared/{0}.mustache" };
    }

    protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
    {
        return GetView(controllerContext, viewPath, masterPath);
    }

    protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
    {
        return GetView(controllerContext, partialPath, null);
    }

    private IView GetView(ControllerContext controllerContext, string viewPath, string masterPath)
    {
        return new NustacheView(this, controllerContext, viewPath, masterPath);
    }
}

Une fois mon moteur de vue créé, je peux l’enregistrer dans la collection des moteurs de vues de l’application (typiquement dans mon global.asax).

ViewEngines.Engines.Add(new NustacheViewEngine());

A partir de là, dans mon projet, je peux transformer ma vue _Products.cshtml en _Products.mustache. Son contenu diffère légèrement puisque je dois maintenant utiliser la syntaxe de Mustache et non plus la syntaxe de Razor.

<ul>
    {{#.}}
        <li>Produit #{{Id}}</li>
    {{/.}}
</ul>

Et là, je peux lancer mon application, et tout marche automatiquement pour le rendu de la vue côté serveur. Pourquoi ? Simplement parce que dans ma vue Index.cshtml, l’extrait de code ci-dessous ne précise pas de type de vue en particulier. En fait, ASP.NET MVC va itérer sur la collection de moteur de vue pour savoir qui peut lui servir une vue qui s’appelle _Products. Dans notre cas, c’est notre moteur de vue fait maison qui sera en mesure de le faire.

<div class="products">
    @Html.Partial("_Products", Model)
</div>

Il nous reste encore quelques détails à régler pour faire fonctionner également le rendu côté client. Le premier consiste à faire descendre mon template Mustache vierge (c’est à dire, sans qu’un modèle ne lui ai été appliqué) chez le client pour pouvoir l’appeler en JavaScript. En fait, pour répondre à cette problématique, je vais simplement créer un HTML Helper qui va lire le contenu du template – soit depuis le système de fichier ou alors depuis le cache si le template a déjà été chargé – et l’écrire dans la réponse HTML.

En pratique, j’écris en fait deux HTML Helpers : un signifiant mon désir d’inclure un template Mustache, l’autre qui me permet de designer l’emplacement dans ma page où les template doivent être affichés. Cela va me permettre de mimer le fonctionnement des instructions @section et @RenderSection afin que mes templates Mustache soient groupés et non pas éparpillés un peu partout dans le DOM. C’est facultatif mais plus propre et plus pratique pour la maintenabilité.

public static class HtmlHelpers
{
    private class MustacheTemplateRenderItem
    {
        private readonly string _templatePath;
        private readonly string _id;

        public string TemplatePath
        {
            get
            {
                return _templatePath;
            }
        }

        public string Id
        {
            get
            {
                return _id;
            }
        }

        public MustacheTemplateRenderItem(string templatePath, string id)
        {
            _templatePath = templatePath;
            _id = id;
        }

        public override bool Equals(object obj)
        {
            if(obj is MustacheTemplateRenderItem)
            {
                var other = obj as MustacheTemplateRenderItem;

                return other.TemplatePath == TemplatePath && other.Id == Id;
            }

            return false;
        }

        public override int GetHashCode()
        {
            return TemplatePath.GetHashCode() ^ Id.GetHashCode();
        }
    }

    private static IList<MustacheTemplateRenderItem> GetMustacheTemplatesRenderList(this HtmlHelper htmlHelper)
    {
        var controllerContext = htmlHelper.ViewContext.Controller.ControllerContext;

        const string key = "MustacheTemplatesRenderList";

        if (controllerContext.HttpContext.Items[key] != null)
        {
            return controllerContext.HttpContext.Items[key] as IList<MustacheTemplateRenderItem>;
        }

        var renderList = new List<MustacheTemplateRenderItem>();

        controllerContext.HttpContext.Items[key] = renderList;

        return renderList;
    }

    public static IHtmlString WithMustacheTemplate(this HtmlHelper htmlHelper, string templateName, string id)
    {
        var controllerContext = htmlHelper.ViewContext.Controller.ControllerContext;
        var controller = htmlHelper.ViewContext.Controller as Controller;
        var viewEngineResult = controller.ViewEngineCollection.FindPartialView(controllerContext, templateName);
        var nustacheView = viewEngineResult.View as NustacheView;
        var templatePath = nustacheView.ViewPath;

        var mustacheTemplatesRenderList = htmlHelper.GetMustacheTemplatesRenderList();

        var mustacheTemplateRenderItem = new MustacheTemplateRenderItem(templatePath, id);

        if(!mustacheTemplatesRenderList.Any(t => t.Equals(mustacheTemplateRenderItem)))
        {
            mustacheTemplatesRenderList.Add(mustacheTemplateRenderItem);
        }

        return MvcHtmlString.Empty;
    }

    public static IHtmlString Mustache(this HtmlHelper htmlHelper)
    {
        var mustacheTemplatesRenderList = htmlHelper.GetMustacheTemplatesRenderList();

        if(!mustacheTemplatesRenderList.Any())
        {
            return MvcHtmlString.Empty;
        }

        var controllerContext = htmlHelper.ViewContext.Controller.ControllerContext;
        var stringBuilder = new StringBuilder();

        foreach (var mustacheTemplateRenderItem in mustacheTemplatesRenderList)
        {
            string templateContent;

            var key = "Nustache:" + mustacheTemplateRenderItem.TemplatePath + "_source";

            if (controllerContext.HttpContext.Cache[key] != null)
            {
                templateContent = controllerContext.HttpContext.Cache[key].ToString();
            }
            else
            {
                var templatePath = controllerContext.HttpContext.Server.MapPath(mustacheTemplateRenderItem.TemplatePath);
                var nustacheTemplateBuilder = NustacheTemplateBuilder.FromFile(templatePath);
                templateContent = nustacheTemplateBuilder.Source;
                controllerContext.HttpContext.Cache.Insert(key, templateContent, new CacheDependency(templatePath));
            }

            var tagBuilder = new TagBuilder("script");

            tagBuilder.Attributes.Add("id", mustacheTemplateRenderItem.Id);
            tagBuilder.Attributes.Add("type", "text/x-mustache-template");

            stringBuilder.AppendLine(tagBuilder.ToString(TagRenderMode.StartTag));
            stringBuilder.AppendLine(templateContent);
            stringBuilder.AppendLine(tagBuilder.ToString(TagRenderMode.EndTag));
        }

        return new MvcHtmlString(stringBuilder.ToString());
    }
}

A l’utilisation, j’aurais donc, dans ma vue Index.cshtml.

@model IEnumerable<WebApplication5.Controllers.Product>

<div>
    <a href="@Url.Action("Index", "Product")" class="random">Random</a>
</div>

<div class="products">
    @Html.Partial("_Products", Model)
</div>

@section scripts {
    <script type="text/javascript">
        $("a.random").click(function (args) {
            args.preventDefault();

            // todo
        });
    </script>
}

@Html.WithMustacheTemplate("_Products", "productsTemplate")

Et dans mon fichier _Layout.cshtml (en bas de ma page).

@Scripts.Render("~/bundles/jquery")
@Scripts.Render("~/bundles/bootstrap")
@RenderSection("scripts", required: false)
@Html.Mustache()

Si je jette un coup d’oeil à mon DOM à l’exécution, je trouve bien mon template, en fin de page. Notez qu’il a bien été placé dans un tag script avec le type adéquat et l’id passé à mon HTML Helper.

<script id="productsTemplate" type="text/x-mustache-template">
    <ul>
        {{#.}}
            <li>Produit #{{Id}}</li>
        {{/.}}
    </ul>
</script>

Dernier point, il faut que je branche mon JavaScript. Là, l’écriture est strictement similaire à ce qui a été vu dans la section précédente sur le templating côté client.

@model IEnumerable<WebApplication5.Controllers.Product>

<div>
    <a href="@Url.Action("Index", "Product")" class="random">Random</a>
</div>

<div class="products">
    @Html.Partial("_Products", Model)
</div>

@section scripts {
    <script type="text/javascript">
        $("a.random").click(function (args) {
            args.preventDefault();

            $.getJSON(this.href, function (data) {
                var $productsTemplate = $("#productsTemplate").html();
                var html = Mustache.render($productsTemplate, data);
                $(".products").html(html);
            });
        });
    </script>
}

@Html.WithMustacheTemplate("_Products", "productsTemplate")

Nous y sommes. Nous continuons à utiliser du templating client, et donc à avoir des données exposées directement en JSON (via un Web API par exemple), mais nous avons réussi à factoriser le template entre le client et le serveur. La solution finale est donc plus simple à intégrer visuellement et plus simple à maintenir.

Enfin, cette technique est à réserver aux cas où le modèle à afficher est uniquement un modèle d’affichage. Exit les formulaires d’insertion ou d’édition donc. Tenter d’inclure ces derniers dans un template mustache serait très complexe et ferait perdre toute la logique provenant des HTML Helpers qui représentent un intérêt principal d’ASP.NET MVC.

Conclusion

Voilà où m’a mené ma réflexion. Cela fait maintenant plusieurs années que je promène cette problématique. Je pense qu’il n’y pas de réponse absolue, ou en tout cas, pas une supportée nativement qui pourrait être mise en place et utilisable par un novice d’ASP.NET MVC. Sur le plan purement théorique, j’aime beaucoup l’approche ASP.NET Web API couplée à Razor. Cependant, elle est trop incertaine pour être utilisable en production, il faut donc la garder dans un coin de mémoire en attendant que Microsoft nous fournisse une formater adapté à Razor (qui sait …). En attendant, l’approche Mustache serveur / client est ma préférée et répond parfaitement à ma problématique. La prochaine étape étant de développer un Bundler / Minifier de template …

Et vous, vous faîtes comment dans vos projets ?

A bientôt,

Nombre de vue : 290

COMMENTAIRES 1 commentaire

  1. Aurélien dit :

    Bon article, merci !
    Petite erreur sur le lien de l’article traitant Jquery Template (“j’en avais déjà parlé ici.”).

AJOUTER UN COMMENTAIRE