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é 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.
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.
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.
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
Enregistrer un commentaire