Avancé

[ASP.NET Core] Plongée dans le routage

ASP.NET logoComme nous le découvrons ensemble depuis plusieurs semaines, ASP.NET Core est une profonde refonte de la plate-forme. L’un des changements majeurs concerne l’abandon du lien fort historique avec IIS. Cela a un gros impact sur certaines fonctionnalités, notamment le routage des requêtes. Aujourd’hui, je vous propose de découvrir les modifications apportées à ce module, qui contrairement aux idées reçues n’est pas fortement lié à ASP.NET MVC.

Pour commencer à jouer avec le routage dans ASP.NET Core, il faut tout d’abord ajouter un paquet nommé Microsoft.AspNet.Routing. Cet article est basé sur la beta6 d’ASP.NET Core. Il se peut donc que les classes, interfaces et méthodes changent légèrement sur les prochaines versions du produit.

"dependencies": {
  "Microsoft.AspNet.Server.IIS": "1.0.0-beta6",
  "Microsoft.AspNet.Server.WebListener": "1.0.0-beta6",
  "Microsoft.AspNet.Routing": "1.0.0-beta6"
}

L’ajout de ce paquet a pour effet de faire apparaître une méthode d’extension UseRouter sur l’interface IApplicationBuilder. Cette méthode prend simplement un paramètre de type IRouter.

Et c’est ce type qui est à l’origine des modifications de conception sur le routage entre les précédentes versions d’ASP.NET et ASP.NET Core. En effet, l’historique paire IRouteHandler, IHttpHandler était très liée à IIS (le concept de IHttpHandler n’existe plus avec OWIN / Katana). L’interface IRouter vient en remplacement de ce tandem, et surtout du traitement qui pouvait être fait du côté du IRouteHandler historique.

L’appel à UseRoute va donc simplement instancier un nouveau middleware de type RouterMiddleware, qui sera simplement lié au IRouter passé en paramètre. Chaque requête HTTP reçue en entrée par le middleware est simplement passée à l’implémentation de IRouter via sa méthode RouteAsync.

Ainsi, on peut imaginer une première implémentation très naïve de cette interface IRouter. Notez que dans ce cas de figure, le code écrit est très semblable à ce que l’on pourrait écrire dans un middleware : je vérifie moi-même que l’url ciblée par la requête HTTP est celle sur laquelle je veux effectuer un traitement.

public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        app.UseRouter(new MyRouter());
    }
}

public class MyRouter : IRouter
{
    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        throw new NotImplementedException();
    }

    public async Task RouteAsync(RouteContext context)
    {
        var requestPath = context.HttpContext.Request.Path.Value;

        if (!string.IsNullOrEmpty(requestPath) && requestPath[0] == '/')
        {
            requestPath = requestPath.Substring(1);
        }

        if(requestPath != "un/test")
        {
            return;
        }

        await context.HttpContext.Response.WriteAsync("hello");
    }
}

Bien sûr, cela n’est pas très pertinent d’avoir un fragment d’url écrit en dur dans le code de mon implémentation de IRouter. Il serait plus cohérent de déporter la logique de correspondance d’une URL avec mon implémentation à un autre niveau, par exemple dans une table de routage.

Regardez à présent l’exemple de code ci-dessous. J’ai transformé l’exemple précédent afin de créer un mécanisme plus générique pour lier une URL à un IRouter.

Notez surtout que les classes RouteTable et UrlRouter que je présente ici implémentent également l’interface IRouter. C’est ici que se trouve le cœur de la magie d’ASP.NET Core. Comme c’est le cas pour les middlewares, le framework résonne toujours avec des poupées russes, c’est à dire des implémentations récursives. Dans mon cas, tout est IRouter : du plus haut niveau, à savoir mon implémentation qui écrit dans la réponse HTTP, jusqu’au plus bas niveau, ma table de routage enregistrée auprès du middleware.

public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        var routeTable = new RouteTable(
            new UrlRouter("un/test", new MyRouter("premier test")),
            new UrlRouter("un/autre/test", new MyRouter("second test"))
            );

        app.UseRouter(routeTable);
    }
}

public class RouteTable : IRouter
{
    private readonly IRouter[] _routers;

    public RouteTable(params IRouter[] routers)
    {
        _routers = routers;
    }

    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        throw new NotImplementedException();
    }

    public async Task RouteAsync(RouteContext context)
    {
        foreach(var router in _routers)
        {
            await router.RouteAsync(context);

            if (context.IsHandled)
            {
                break;
            }
        }
    }
}

public class UrlRouter : IRouter
{
    private readonly string _url;
    private readonly IRouter _router;

    public UrlRouter(string url, IRouter router)
    {
        _url = url;
        _router = router;
    }

    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        throw new NotImplementedException();
    }

    public async Task RouteAsync(RouteContext context)
    {
        var requestPath = context.HttpContext.Request.Path.Value;

        if (!string.IsNullOrEmpty(requestPath) && requestPath[0] == '/')
        {
            requestPath = requestPath.Substring(1);
        }

        if (requestPath != _url)
        {
            return;
        }

        await _router.RouteAsync(context);
    }
}

public class MyRouter : IRouter
{
    private readonly string _text;

    public MyRouter(string text)
    {
        _text = text;
    }

    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        throw new NotImplementedException();
    }

    public async Task RouteAsync(RouteContext context)
    {
        context.IsHandled = true;

        await context.HttpContext.Response.WriteAsync(_text);
    }
}

Et voilà, nous venons de reproduire le fonctionnement du routage d’ASP.NET Core. L’implémentation complète utilise exactement le même principe, mais en ajoutant des fonctionnalités supplémentaires (paramètres dans les routes, contraintes, rétro génération d’URL).

Ci-dessous, le même exemple, mais en utilisant cette fois-ci les types de l’implémentation officielle. Notez que ma classe RouteTable devient ici une classe RouteCollection. Mon UrlRouter devient TemplateRoute.

public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        var routeCollection = new RouteCollection();

        routeCollection.Add(new TemplateRoute(new MyRouter("premier test"), "un/test", null));
        routeCollection.Add(new TemplateRoute(new MyRouter("second test"), "un/autre/test", null));

        app.UseRouter(routeCollection);
    }
}

public class MyRouter : IRouter
{
    private readonly string _text;

    public MyRouter(string text)
    {
        _text = text;
    }

    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        throw new NotImplementedException();
    }

    public async Task RouteAsync(RouteContext context)
    {
        context.IsHandled = true;

        await context.HttpContext.Response.WriteAsync(_text);
    }
}

L’utilisation de la classe TemplateRoute permet de disposer d’une instance de RouteData automatiquement alimentée par les paramètres extraits depuis la route.

Je peux modifier mon exemple précédent afin d’afficher ces valeurs dans le corps de la réponse. Notez au passage l’usage des string interpolations de C#6 (j’adore cette syntaxe).

public class MyRouter : IRouter
{
    private readonly string _text;

    public MyRouter(string text)
    {
        _text = text;
    }

    public VirtualPathData GetVirtualPath(VirtualPathContext context)
    {
        throw new NotImplementedException();
    }

    public async Task RouteAsync(RouteContext context)
    {
        var routeValues = string.Join("", context.RouteData.Values);
        var message = $"{_text} - {routeValues}";
        await context.HttpContext.Response.WriteAsync(message);
        context.IsHandled = true;
    }
}

Reste ensuite à changer mes templates de route afin d’y faire apparaître des paramètres.

public class Startup
{
    public void Configure(IApplicationBuilder app)
    {
        var routeCollection = new RouteCollection();

        routeCollection.Add(new TemplateRoute(new MyRouter("premier test"), "un/test/{parametre1}/{parametre2}", null));
        routeCollection.Add(new TemplateRoute(new MyRouter("second test"), "un/autre/test", null));

        app.UseRouter(routeCollection);
    }
}

Et dans mon navigateur, pour l’url http://localhost:13660/un/test/salut/leo.

premier test - [parametre1, salut][parametre2, leo]

Tout comme les précédentes versions ASP.NET MVC disposait de ses propres implémentations de IRouterHandler et de IHttpHandler chargées d’extraire des paramètres depuis l’url courante, ASP.NET MVC 6 fonctionne sur le même principe (mais avec un IRouter).

Pour mes exemples suivants, j’ai changé la liste de dépendances de mon projet comme ce qui suit. Notez que j’ai ajouté le paquet d’ASP.NET MVC mais que j’ai retiré le paquet du routage. En effet, celui-ci est automatiquement tiré car il s’agit d’une dépendance d’ASP.NET MVC.

"dependencies": {
  "Microsoft.AspNet.Server.IIS": "1.0.0-beta6",
  "Microsoft.AspNet.Server.WebListener": "1.0.0-beta6",
  "Microsoft.AspNet.Mvc": "6.0.0-beta6"
}

Ainsi, lorsque vous écrivez le code ci-dessous, pas besoin de créer la collection de routes comme dans mon précédent exemple, c’est fait automatiquement par la méthode UseMvc.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseMvc();
    }
}

Notez que, par défaut, aucune route n’est ajoutée, pas même la route historique par défaut d’ASP.NET MVC (ce comportement est différent des précédentes versions de la bêta : historiquement la route par défaut était ajoutée de manière masquée, mais les développeurs sont revenus en arrière). La création automatisée de cette route par défaut est néanmoins possible par l’utilisation de la méthode UseMvcWithDefaultRoute.

public class Startup
{
    public void ConfigureServices(IServiceCollection serviceCollection)
    {
        serviceCollection.AddMvc();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseMvcWithDefaultRoute();
    }
}

Conclusion

ASP.NET Core possède un mécanisme de routage qui a été totalement revu. Si me suis attardé ici sur le fonctionnement interne de ce mécanisme, je reviendrai dans un autre billet sur les nouveautés dans le routage qui sont spécifiques à ASP.NET MVC 6.

Pour plus d’informations sur le sujet, il n’y a rien de mieux que d’aller explorer les sources : https://github.com/aspnet/Routing.

Nombre de vue : 157

AJOUTER UN COMMENTAIRE