CustomRoute .Net MVC5


Comment router des URLs "hard coded" optimisées pour le référencement, dans .Net MVC 5 ?


      Introduction

Le routage Asp.Net MVC est extrêmement pratique pour faire face aux problématiques de référencement : l'URL "/product/barbecue-15" peut être interprétée pour afficher les informations relatives au produit d'id 15. Cependant nous ne voulions pas avoir ces identifiants dans nos urls pour qu'elles soient plus propres et donner toute la liberté aux rédacteurs pour choisir quelle URL affiche un article (et donc éviter de "/product") par exemple ("/jardin/barbecue-pas-cher." affiche les informations relatives au produit 15).

Nouvel Objet – CustomRoute

Pour commencer, nous avons dû créer un nouvel objet : CustomRoute. En héritant de la classe RouteBase du framework Asp.Net MVC 5 qui permet de gérer le routage des requêtes, nous allions pouvoir nous servir de cet objet pour intercepter les requêtes entrantes (router le trafic vers le bon combo Controller / Action / Paramètre) mais aussi les demandes d’urls (récupérer l’url correspondant à un combo Controller / Action / Paramètre).


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class CustomRoute : RouteBase
{
    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        //mécanique de récupération des paramètres de la route à partir d’une url
        //appelé à chaque requête entrante sur l’appli
    }

    public override VirtualPathData GetVirtualPath(RequestContext context, RouteValueDictionary values)
    {
        //mécanique de récupération d’une url à partir des paramètres
        //appelé quand on appelle un Url.Action() 
    }
}


La classe ne fait qu’hériter de la classe de System.Web.Routing.RouteBase (Documentation MSDN), réécrit les deux méthodes nécessaires afin d’utiliser notre algorithme de récupération de paramètres ou d’url (que je vais détailler plus loin).

Il ne nous reste plus qu’à enregistrer la route dans le fichier Global.asax.cs dans la méthode Application_Start.

1
RouteTable.Routes.Add(new CustomRoute());


Nouveau Modèle – RewrittenUrl

Maintenant que notre classe de gestion des routes custom est créée, nous avons besoin de lui fournir les informations nécessaires pour l'algorithmique. C’est pourquoi nous avons créé un nouveau modèle pour nos routes : RewrittenUrl.

Cette classe va nous servir de liens entre une URL et ses paramètres.


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class RewrittenUrl    
{
    public string Lg { get; set; }
    public string Id { get; private set; }
    public string Controller { get; set; }
    public string Action { get; set; }
    public List<RewrittenUrlParameter> Params { get; set; }
    public string Url { get; set; }
    public string ControllerNameSpace { get; set; }

    public RewrittenUrl() { }

    public RewrittenUrl(string url, string controller, string action, string lg, List<RewrittenUrlParameter> p, string controllerNameSpace = "PortailMVCDataLayer.Controllers")
    {
        this.Url = url;

        if (!this.Url.StartsWith("/"))
                this.Url = "/" + this.Url;

        this.Id = lg + "#" + Url;
        this.Action = action;
        this.Params = p ?? new List<RewrittenUrlParameter>();
        this.Lg = lg;
        this.Controller = controller;
        this.ControllerNameSpace = controllerNameSpace;

        if (!this.Params.Exists(a => a.Key == "lg"))
            this.Params.Add(new RewrittenUrlParameter("lg", lg));
    }

    public string ToQueryString()
    {
        return GetQueryString(Controller, Action, Lg, Params);
    }

    public static string GetQueryString(string controller, string action, string lg, List<RewrittenUrlParameter> rewrittenUrlParameters)
    {
        NameValueCollection nvc = HttpUtility.ParseQueryString(String.Empty);
        nvc.Set("controller", controller);
        nvc.Set("action", action);
        nvc.Set("lg", lg);
        rewrittenUrlParameters.ForEach(p => nvc.Set(p.Key, p.Value));
        var dicokey = string.Join("&", nvc.AllKeys.OrderBy(k => k).Select(k => k + "=" + HttpUtility.UrlEncode(nvc[k]))).ToLower(); 
        return dicokey;
    }        
}

On peut constater que cette classe contient à la fois l’url réécrite dans sa propriété Url, les paramètres de l’URL dans l’objet Params (qui est une liste de clef/valeur que l’on a réécrit pour des problématiques de sérialisation), et les paramètres nécessaires à MVC : Controller et Action. La propriété ControllerNamespaces permet de gérer le namespace dans lequel est le contrôleur si l'application en référence plusieurs de même nom.
La propriété Id va nous permettre de repérer une url dans la liste d’URLs générées et est obtenue en concaténant la langue de la page avec l’url (sur nos sites la langue est souvent déduite du nom de domaine). Grâce à cet identifiant, l’application sera capable de récupérer la RewrittenUrl correspondante à cet identifiant.
Pour finir, la méthode ToQueryString va nous permettre de comparer une RewrittenUrl avec un RouteData. En effet lorsque le framework Asp.Net MVC va appeler la méthode GetVirtualPath suite à un Url.Action, la liste de paramètres RouteValueDictionary va être transformée en querystring. On pourra ensuite tenter de récupérer une URL réécrite à partir de cette querystring.



1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public override RouteData GetRouteData(HttpContextBase httpContext)
{ 
    //récupération de la configuration de mon application
    var config = configloader.GetConfig(); 

    //je construis un identifiant RewrittenUrl grâce à la langue en cours et l’url qui est accédée
    var key = config.RequestLanguage + "#" + httpContext.Request.Url.AbsolutePath.ToLower();     

    //j’instancie mon objet de retour
    RouteData res = new RouteData(this, new MvcRouteHandler()) ;

    //je charge mon datasource de RewrittenUrl et je tente de récupérer 
l’url correspondante à l’id. On reviendra après sur ce qui nous 
permet de charger les datasources
    RewrittenUrl rewrittenUrl;
    Dictionnary<string, RewrittenUrl> datasource = _monProviderDeDatasource.LoadUrls();

    //le fait de retourner null lorsque l’url n’est pas trouvée permet à MVC de continuer la résolutuion des routes dans l’ordre dans lesquelles elles sont déclarées dans le fichier RouteConfig.cs
    if( !datasource.TryGetValue(key, out rewrittenUrl))
        return null;

    //je charge ensuite les paramètres de la RewrittenUrl trouvée dans l’objet RouteData puis je retourne l’objet. Mvc va ensuite continuer l’execution de son code et instancier le bon controller pour executer l’action de ma route.
    res.Values["controller"] = rewrittenUrl.Controller;
    res.Values["action"] = rewrittenUrl.Action;
    res.DataTokens["UseNamespaceFallback"] = false;

    //je set le namespace du controleur définit pas l’url sinon je prends le namespace par défaut (qui est passé en paramètre du constructeur de CustomRoute).
    res.DataTokens["Namespaces"] = _controllerNamespaces.Any() ? _controllerNamespaces : new[] { rewrittenUrl.ControllerNameSpace };

    //j’ajoute chaque parametre
    foreach (var param in RewrittenUrl.Params)
    {
        res.Values[param.Key] = param.Value;
    }

    return res;
}


Ceci est le code appelé par MVC lorsqu’une url se présente sur l’application. Par défaut dans son système de routes, MVC va regarder, dans l’ordre de leur déclaration, les différentes routes et les comparer à l'URL demandée. Il va ensuite transformer cette URL en un objet RouteData qui lui permettra d’instancier un contrôleur puis d’exécuter une action.
Dans notre cas, les paramètres ne sont pas directement spécifiés dans la route. La route nous permet juste de rentrer dans la méthode qui va nous permettre de regarder si un objet RewrittenUrl correspondant à l'URL demandée existe dans notre source de données.
C’est ensuite cet objet, qui contient les différents paramètres nécessaires à l’exécution de la route qui va nous permettre de créer puis retourner ce RouteData. MVC pourra ensuite instancier le controller, puis exécuter l’action avec les paramètres du RouteData.
Il est important de noter que si une url n’est pas gérée, on retourne null, MVC passe à la route suivante, dans l’ordre de leur déclaration dans le RouteConfig.



 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public override VirtualPathData GetVirtualPath(RequestContext context, RouteValueDictionary values)
{
    //récupération de la configuration de mon application
    var config = configloader.GetConfig();

    //je crée mon NameValueCollection qui me servira à comparer mes urls et j’y insère les paramètres de la route que je veux récupérer.
    NameValueCollection nvc = new NameValueCollection();

    foreach (KeyValuePair kvp in values)
    {
        if (kvp.Value == null) 
            continue;

        nvc.Add(kvp.Key, kvp.Value as string ?? kvp.Value.ToString());
     }

    //je transforme mon nvc en querystring ordonnée qui va me permettre de trouver la RewrittenUrl associée.
    var queryString = RewrittenUrlProviderBase.ToOrderedQueryString(nvc);

    //je récupère mon datasource querystring // url et je regarde si la querystring du nvc en cours est dedans. On reviendra après sur ce qui nous permet de charger les datasources
    Dictionnary<string, string> datasource = _monProviderDeDatasource.LoadUrls();
    
    if (_rewrittenUrlProviders.LoadVirtualPaths().TryGetValue(queryString, out url))
    {                   
        url = url.TrimStart('/');
    }   
    else 
    {
        //le fait de retourner null permet à MVC de continuer la résolution des routes dans l’ordre dans lesquelles elles sont déclarées dans le fichier RouteConfig.cs
        return null;
    }

    //on finit par retourner l’objet virualPath.
    var virtualPathData = new VirtualPathData(this, url);

    return virtualPathData;
}


Ci-dessus se trouve le code qui est appelé lorsqu’un appel à Url.Action est réalisé. L’objet RouteValueDictionary contient les différents paramètres passés à Url.Action (y compris l’action et le contrôleur). Ce dictionnaire est ensuite transformé en NameValueCollection pour que l’on puisse appeler la méthode ToQueryString et ainsi aller chercher dans le datasource l’url,s'il en existe une qui correspond aux paramètres passés.

Tout comme pour la méthode GetRouteData vue au-dessus, il nous faut retourner null dans le cas où aucune url n’existe pour que MVC continue de résoudre la recherche d’url via les autres routes, toujours dans l’ordre de leur déclaration dans le fichier RouteConfig.cs.
Pour finir, il faut retourner un objet VirualPathData qui s’obtient facilement via new VirtualPathData(this, url) (l’url ne DOIT PAS commencer par un « / »), il sera rajouté par l’objet.

Les datasources


Les datasources sont créés via des classes que l’on appelle des RewrittenUrlProvider et qui implémentent toutes l’interface suivante.


1
2
3
4
5
6
public interface IRewrittenUrlProvider
{
    IDictionary<string, RewrittenUrl> LoadUrls();

    IDictionary<string, string> LoadVirtualPaths();
}

Chaque provider possède sa propre logique de génération d’url. Ce sont ces objets qui vont permettre de récupérer nos datasources.


Exemple : ArticleRewrittenUrlProvider

Pour bien comprendre, je vais décrire un de nos provider : ArticleRewrittenUrlProvider. Ce provider permet de créer des RewrittenUrl pour les articles rédigés dans un CMS perso.


1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class ArticleRewrittenUrlProvider : IRewrittenUrlProvider
{
    private readonly ICmsLoader _cmsLoader;

    public ArticleRewrittenUrlProvider()
    {
        _cmsLoader = new CmsLoader();
    }

    public override IDictionary<string, RewrittenUrl> LoadUrls()
    {
        var retour = new Dictionary<string, RewrittenUrl>();

        //récupération de toutes les id article présentes dans le CMS.
        var articleIds = _ cmsLoader.GetArticlesIds();
            
        //pour chaque id, je génère un objet RewrittenUrl
        var allRoutesArticles = articleIds.Select(id =>  GetUrlForArticle(_cmsLoader.GetArticle(id))).EmptyIfNull();

        foreach (var route in allRoutesArticles)
        {
            retour.Add(route.Id, route);
        }

        return retour; 
    }

    private RewrittenUrl GetUrlForArticle (ArticleDTO article)
    {            
        return new RewrittenUrl(article.Url,
            "Main",//controller
            "Article",//action
             article.LanguageIso,//langue route
            new List<RewrittenUrlParameter>()
            {
               new RewrittenUrlParameter("id", article.Id.ToString())
            }
        );
    }
}


Dans cet exemple, grâce à un objet CmsLoader, je vais récupérer les différents articles pour lesquels je veux générer une url. Grâce aux informations contenues dans un article, je peux lui créer un objet RewrittenUrl associés. Je crée ensuite un dictionnaire qui sera retourné lorsque mon CustomRoute fera appel à LoadUrls().

Intégrer ces providers dans CustomRoute

Pour permettre à CustomRoute d’utiliser ces providers, on le fait tout simplement passer en paramètre du constructeur, en même temps que les namespaces si besoin.


1
2
3
4
5
public CustomRoute(IRewrittenUrlProvider rewrittenUrlProviders, IEnumerable<string> controllerNamespaces)
{
    _rewrittenUrlProviders = rewrittenUrlProviders;
    _controllerNamespaces = controllerNamespaces;
}

Maintenant, notre objet CustomRoute possède un provider de RewrittenUrl qui lui fournira le datasource nécéssaire à sa logique de récupération de RouteData ou de VirtualPath.


Multiple providers, comment faire ?

  L’objet CustomRoute n’accepte qu’un seul RewrittenUrlProvider en paramètre de son constructeur. Cependant, dans de nombreux projets (et y compris pour nous), il existe de multiples logiques de génération d’une url. Pour faire passer plusieurs providers à CustomRoute, il nous suffit d’en créer un seul qui va en contenir plusieurs, en implémentant le composite pattern :



 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class MultipleRewrittenUrlProvider : IRewrittenUrlProvider
{

    public MultipleRewrittenUrlProvider(IEnumerable<IRewrittenUrlProvider> routesProviders)
    {
        _routesProviders = routesProviders.ToList();
    }

    public override IDictionary<string, RewrittenUrl> LoadUrls()
    {
        //on appelle le LoadUrls de chaque provider pour créer un gros dictionnaire de RewrittenUrl
        var dico = new IDictionary<string, RewrittenUrl>();

        foreach(var provider in _routesProviders)
        {
            var dicoForThisProvider = provider.LoadUrls();

            foreach(var route in dicoForThisProvider)
            {
                dico.Add(route.Key, route.Value);
            }
        }
        return dico;
    }
}

Ce provider permet donc d’appeler le LoadUrls de tous les providers passés en paramètres. Et la logique est similaire pour la méthode LoadVirtualPath.
Nous implémentons également un Decorator pattern pour gérer le cache de nos RewrittenUrl. En effet, pour des raisons de performances, toute cette logique n’est exécutée qu’une seule fois, puis mise en cache via un CachedRewrittenUrlProvider.


Commentaires

Posts les plus consultés de ce blog

Implémenter une authentification par token sur un projet .NET Core

Gérer la configuration des machines virtuelles avec Azure Automation et PowerShell DSC

WebAssembly et Blazor