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

ASP.NET logo

Ce premier billet est pour moi l’occasion de revenir sur une problématique très courante dans le développement ASP.NET : le choix entre ASP.NET MVC / ASP.NET Web API et le choix entre exposer du HTML et exposer du JSON. L’approche se faisant 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.

La problématique

Depuis plusieurs années maintenant, dans les applications Web, la mode est au rafraîchissement partiel de la page. En fait, depuis qu’AJAX est arrivé, il s’est rapidement généralisé à la majorité des sites présents sur internet. Pourtant, ce mode de fonctionnement amène une question à laquelle les développeurs peinent à répondre : cette mise à jour d’une partie de la page doit être se faire en requêtant une URL qui retourne du HTML ou en requêtant une URL qui retourne des données en JSON (ou tout autre format d’échange de données) ?

Les deux approches présentent des avantages :

  • Si la requête est faite sur une URL qui retourne du HTML, la logique et la génération de la vue restent entièrement côté serveur. Le code client doit se contenter de prendre le résultat d’exécution de la requête et de le placer dans le conteneur cible. En utilisant jQuery, cela se traduit par un simple appel à la méthode $.load.
  • Si la requête est faite sur une URL qui retourne directement des données, le volume d’informations échangées sera vraisemblablement plus léger puisqu’il ne contient pas toute la structure visuelle nécessaire à l’affichage des informations. C’est surtout le cas lorsque la requête est faite sur une collection de données (une liste de clients ou de produits par exemple) ; la même structure visuelle est inutilement recopiée pour chaque élément de la collection. De plus, l’API qui ne retourne que des données sans les formater en HTML devient une API utilisable par d’autres types de client qu’une application Web. On pourrait donc factoriser ce code pour le réutiliser dans nos clients mobiles ou tablettes par exemple.

Naturellement, mon cœur penche plutôt pour la seconde solution. J’aime l’idée qu’une seule et même API puisse servir plusieurs clients : une application Web et une application mobile par exemple. Ces derniers prennent alors la responsabilité de façonner et d’afficher l’information.

Dans cette série de billets, je vais revenir sur les différentes approches que j’ai pu mettre en pratique sur des projets en utilisant ASP.NET MVC.

La solution basique, du HTML pour tout le monde

Avec ASP.NET MVC c’est la plus simple à mettre en place. Je vais simplement utiliser les mécanismes de vues partielles et de composition de vues pour créer des actions qui exposent uniquement des morceaux de pages. Ces morceaux de page seront ensuite consommés par mes différents clients : page via AJAX ou clients mobiles (ouch …).

Dans l’exemple de code suivant, j’utilise la méthode d’extension IsAjaxRequest pour savoir si je dois servir la vue complète (cas d’accès de la page classique via le navigateur) ou uniquement la liste des produits sous la forme d’une vue partielle (cas de la mise à jour d’un morceau de la page via JQuery).

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())
        {
            return PartialView("_Products", products);
        }

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

public class Product
{
    public int Id { get; set; }
}

Et donc les deux vues sont réparties dans un fichier 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();
            $(".products").load(this.href);
        });
    </script>
}

Et dans un fichier _Products.cshtml.

@model IEnumerable<WebApplication5.Controllers.Product>

<ul>
    @foreach (var product in Model)
    {
        <li>Produit #@product.Id</li>
    }
</ul>

Il est temps de faire le bilan de cette technique.

Avantages :

  • Facile et rapide à mettre en place ;
  • Supporté complètement nativement ;
  • La vue reste partagée entre le scénario “pleine page” et l’autre.

Et les inconvénients :

  • Ce n’est pas adapté à des clients mobiles / tablettes ou externes ;
  • Les échanges sont plus lourd que si j’envoyais des données brutes ;
  • J’ai dû ajouter un conditionnement dans mon code pour traiter les deux cas.

La solution de la réponse conditionnée

L’inconvénient de la solution précédente est qu’elle ne retourne que du HTML. Or, pour mes clients mobiles, ce n’est pas une solution acceptable. Je souhaite donc avoir une approche où, tout en factorisant mon code serveur, je sais servir soit du JSON, soit du HTML.

Avec ASP.NET MVC, je peux facilement typer mes actions avec ActionResult et donc, retourner des types de contenu très différents, c’est déjà ce mécanisme qui me servait en retourner une vue complète ou une vue partielle dans l’exemple précédent. En analysant la provenance de ma requête, je peux donc choisir de retourner soit une vue construite en HTML, soit du JSON. La seule contrainte étant de marquer les requêtes qui proviennent de mes clients mobiles d’une certaine manière afin de prendre la bonne décision dans le code de mon action.

Dans mon premier exemple, lorsque j’affiche la page complète, ou lorsque la requête de l’appel $.load part, un header HTTP Accept avec la valeur text/html est automatiquement envoyé au serveur. Je peux donc mimer ce fonctionnement en traitant les cas où ce header contient une autre valeur (ex. application/json) et en retournant alors des données brutes plutôt qu’une vue ou une vue partielle.

L’exemple ci-dessous est une application de ce principe. Le fonctionnement précédent reste le même, mais les clients mobiles peuvent maintenant consommer l’action Index de mon contrôleur Product, simplement en ajoutant l’en-tête HTTP Accept.

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.AcceptTypes.Contains("application/json"))
        {
            return Json(products, JsonRequestBehavior.AllowGet);
        }

        if(Request.IsAjaxRequest())
        {
            return PartialView("_Products", products);
        }

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

Avantages :

  • Est totalement supporté nativement ;
  • La vue reste partagée entre le scénario “pleine page” et l’autre ;
  • Je peux adapter mon action pour qu’elle soit consommée par des clients mobiles / tablettes ou externes.

Et les inconvénients :

  • J’ai dû ajouter un conditionnement dans mon code pour traiter les deux cas.
  • La solution devient plus complexe à implémenter, même si j’aurais pu rendre le tout plus réutilisable en créant un type de retour d’action personnalisé qui engloberait ces différents tests.

La solution de la réponse conditionnée bis (aka. celle qui est théoriquement très intéressante)

La précédente solution se rapproche du fonctionnement de la négociation de contenu d’ASP.NET Web API. Du coup, plutôt que de chercher à recréer la roue, je peux éventuellement prendre le problème dans l’autre sens : plutôt que de sélectionner le meilleur type de résultat moi-même, pourquoi ne pas me concentrer sur le traitement purement métier et laisser le Framework s’occuper de la représentation des données ?

Avec ASP.NET Web API, il me suffit donc de créer un nouveau formateur de données qui comprend l’en-tête Accept text/html. Si vous n’avez pas lu ma série d’articles sur Razor, je vous invite à y jeter un petit coup d’œil, c’est ici.

Avant d’écrire le formateur en lui-même, je commence par faire un attribut View qui me servira à décorer les actions de mon API. C’est cet attribut qui me permettra de pouvoir retrouver la vue correspondant au couple contrôleur / action invoqué, ou éventuellement de spécifier un nom de vue différent du nom de l’action courante.

public class ViewConfiguration
{
    public string Controller { get; set; }
    public string View { get; set; }
}

public class ViewEnabledAttribute : System.Web.Http.Filters.ActionFilterAttribute
{
    private readonly string _viewName;

    public ViewEnabledAttribute()
        : this(string.Empty)
    {

    }

    public ViewEnabledAttribute(string viewName)
    {
        _viewName = viewName;
    }

    public override void OnActionExecuting(System.Web.Http.Controllers.HttpActionContext actionContext)
    {
        var actionName = actionContext.ActionDescriptor.ActionName;

        if (!string.IsNullOrEmpty(_viewName))
        {
            actionName = _viewName;
        }

        var controllerName = actionContext.ControllerContext.ControllerDescriptor.ControllerName;

        actionContext.Request.Properties.Add(new KeyValuePair<string, object>("ViewConfiguration", new ViewConfiguration
            {
                Controller = controllerName,
                View = actionName
            }));

        base.OnActionExecuting(actionContext);
    }
}

Dans un second temps, je peux écrire mon API en reprenant le même contexte que les exemples suivants. Notez que cette fois ci mon API retourne une liste de POCO et n’est plus fortement couplée ni à une vue ou à du JSON.

[RoutePrefix("api/v1/products")]
public class ApiProductController : ApiController
{
    private 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 };
        }
    }

    [ViewEnabled]
    [Route("list"), HttpGet]
    public IEnumerable<Product> Index()
    {
        var products = GetProducts().ToList();

        return products;
    }
}

Dernière étape, l’écriture du formateur. C’est toute la difficulté de ce scénario, il faut réécrire une grosse partie de la roue (ce que fait normalement le Razor host d’ASP.NET MVC). L’implémentation suivante est très naïve et se base sur le projet RazorEngine (disponible sur Nuget).

public class RazorMediaTypeFormatter : BufferedMediaTypeFormatter
{
    public RazorMediaTypeFormatter()
    {
        SupportedMediaTypes.Add(new System.Net.Http.Headers.MediaTypeHeaderValue("text/html"));
    }

    private class InnerRazorMediaTypeFormatter : BufferedMediaTypeFormatter
    {
        private readonly ViewConfiguration _viewConfiguration;

        public InnerRazorMediaTypeFormatter(ViewConfiguration viewConfiguration)
        {
            _viewConfiguration = viewConfiguration;

            SupportedMediaTypes.Add(new System.Net.Http.Headers.MediaTypeHeaderValue("text/html"));
        }

        public override bool CanReadType(Type type)
        {
            return false;
        }

        public override bool CanWriteType(Type type)
        {
            return true;
        }

        private System.Text.Encoding GetEncoding(HttpContent content)
        {
            System.Text.Encoding encoding = null;

            foreach (var e in content.Headers.ContentEncoding)
            {
                encoding = System.Text.Encoding.GetEncoding(e);

                if (encoding != null)
                {
                    break;
                }
            }

            if (encoding == null)
            {
                encoding = System.Text.Encoding.UTF8;
            }

            return encoding;
        }

        public override void WriteToStream(Type type, object value, System.IO.Stream writeStream, System.Net.Http.HttpContent content)
        {
            var viewPath = HttpContext.Current.Server.MapPath(@"~\Views\" + _viewConfiguration.Controller + @"\" + _viewConfiguration.View + ".cshtml");

            var templateContent = File.ReadAllText(viewPath);

            var result = Razor.Parse(templateContent, value);

            var encoding = GetEncoding(content);

            var bytes = encoding.GetBytes(result);

            writeStream.Write(bytes, 0, bytes.Length);
        }
    }

    public override MediaTypeFormatter GetPerRequestFormatterInstance(Type type, System.Net.Http.HttpRequestMessage request, System.Net.Http.Headers.MediaTypeHeaderValue mediaType)
    {
        ViewConfiguration viewConfiguration = null;

        if (request.Properties.ContainsKey("ViewConfiguration"))
        {
            viewConfiguration = request.Properties["ViewConfiguration"] as ViewConfiguration;

            return new InnerRazorMediaTypeFormatter(viewConfiguration);
        }

        return base.GetPerRequestFormatterInstance(type, request, mediaType);
    }

    public override bool CanReadType(Type type)
    {
        return false;
    }

    public override bool CanWriteType(Type type)
    {
        return true;
    }
}

Il ne me reste plus qu’à enregistrer ce formateur dans mon application et c’est mission accomplie. Si j’attaque mon API avec l’entête Accept text/html, l’API me sert une vue (c’est la préférence principale exprimée par un navigateur). Si je demande du JSON, l’API me retourne bien mes objets formatés en JSON.

GlobalConfiguration.Configure(c =>
{
    c.MapHttpAttributeRoutes();

    c.Formatters.Add(new RazorMediaTypeFormatter());
});

Créer un formateur basé sur Razor et contenant tout le tooling à disposition dans ASP.NET MVC est une chose plutôt ardue. Ce que je vous propose ci-dessous est une implémentation vraiment très basique en utilisant le paquet Nuget. La rendre utilisable dans un vrai projet en production est une tâche très lourde et qu’il faut probablement écarter.

Dans la seconde partie, je parlerai des approches mixtes JSON / HTML appliquées notamment grâce au templating.

A bientôt,

Nombre de vue : 325

COMMENTAIRES 2 commentaires

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

  2. Guy dit :

    Merci pour ce retour d’expérience et la clarté des explicaitions.
    Avant de me jetter à la lecture de le seconde partie, j’aurais quand même appuyer que même dans certaines organisations, on choisi plutot la simplicité :
    – on utilise le tandem asp.net mvc + le moteur de rendu Razor pour répondre aux requêtes sur une application web classique
    – on utilise WebApi pour répondre à la consommation des données provenant souvent d’un mobile, d’une tablette, ou tout simplement d’un web service externe
    Dans les deux cas, controleur WebApi ou contrôleur MVC ne sont que des wrappers et consomment tous deux les données provenant d’une couche Buisiness

AJOUTER UN COMMENTAIRE