Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Change route collection of MVC6 after startup

Tags:

In MVC-5 I could edit the routetable after initial startup by accessing RouteTable.Routes. I wish to do the same in MVC-6 so I can add/delete routes during runtime (usefull for CMS).

The code to do it in MVC-5 is:

using (RouteTable.Routes.GetWriteLock()) {     RouteTable.Routes.Clear();      RouteTable.Routes.IgnoreRoute("{resource}.axd/{*pathInfo}");     RouteTable.Routes.MapRoute(         name: "Default",         url: "{controller}/{action}/{id}",         defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }     ); } 

But I can't find RouteTable.Routes or something similar in MVC-6. Any idea how I can still change the route collection during runtime?


I want to use this principle to add, for example, an extra url when a page is created in the CMS.

If you have a class like:

public class Page {     public int Id { get; set; }     public string Url { get; set; }     public string Html { get; set; } } 

And a controller like:

public class CmsController : Controller {     public ActionResult Index(int id)     {         var page = DbContext.Pages.Single(p => p.Id == id);         return View("Layout", model: page.Html);     } } 

Then when a page is added to the database I recreate the routecollection:

var routes = RouteTable.Routes; using (routes.GetWriteLock()) {     routes.Clear();     foreach(var page in DbContext.Pages)     {         routes.MapRoute(             name: Guid.NewGuid().ToString(),             url: page.Url.TrimEnd('/'),             defaults: new { controller = "Cms", action = "Index", id = page.Id }         );     }      var defaultRoute = routes.MapRoute(         name: "Default",         url: "{controller}/{action}/{id}",         defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }     ); } 

In this way I can add pages to the CMS that do not belong in conventions or strict templates. I can add a page with url /contact, but also a page with url /help/faq/how-does-this-work.

like image 343
SynerCoder Avatar asked Sep 14 '15 13:09

SynerCoder


People also ask

Where you configure route in MVC?

Configure a Route Every MVC application must configure (register) at least one route configured by the MVC framework by default. You can register a route in RouteConfig class, which is in RouteConfig. cs under App_Start folder.

What is the default setting for route config in MVC?

Routing in ASP.NET MVC By default route is: Home controller - Index Method. routes. MapRoute has attributes like name, url and defaults like controller name, action and id (optional).

What is default routing in MVC?

The Default route maps the first segment of a URL to a controller name, the second segment of a URL to a controller action, and the third segment to a parameter named id. The Default route maps this URL to the following parameters: controller = Home. action = Index.


1 Answers

The answer is that there is no reasonable way to do this, and even if you find a way it would not be a good practice.

An Incorrect Approach to the Problem

Basically, the route configuration of MVC versions past was meant to act like a DI configuration - that is, you put everything there in the composition root and then use that configuration during runtime. The problem was that you could push objects into the configuration at runtime (and many people did), which is not the right approach.

Now that the configuration has been replaced by a true DI container, this approach will no longer work. The registration step can now only be done at application startup.

The Correct Approach

The correct approach to customizing routing well beyond what the Route class could do in MVC versions past was to inherit RouteBase or Route.

AspNetCore (formerly known as MVC 6) has similar abstractions, IRouter and INamedRouter that fill the same role. Much like its predecessor, IRouter has just two methods to implement.

namespace Microsoft.AspNet.Routing {     public interface IRouter     {         // Derives a virtual path (URL) from a list of route values         VirtualPathData GetVirtualPath(VirtualPathContext context);          // Populates route data (including route values) based on the         // request         Task RouteAsync(RouteContext context);     } } 

This interface is where you implement the 2-way nature of routing - URL to route values and route values to URL.

An Example: CachedRoute<TPrimaryKey>

Here is an example that tracks and caches a 1-1 mapping of primary key to URL. It is generic and I have tested that it works whether the primary key is int or Guid.

There is a pluggable piece that must be injected, ICachedRouteDataProvider where the query for the database can be implemented. You also need to supply the controller and action, so this route is generic enough to map multiple database queries to multiple action methods by using more than one instance.

using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Caching.Memory; using System; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Threading.Tasks;  public class CachedRoute<TPrimaryKey> : IRouter {     private readonly string _controller;     private readonly string _action;     private readonly ICachedRouteDataProvider<TPrimaryKey> _dataProvider;     private readonly IMemoryCache _cache;     private readonly IRouter _target;     private readonly string _cacheKey;     private object _lock = new object();      public CachedRoute(         string controller,          string action,          ICachedRouteDataProvider<TPrimaryKey> dataProvider,          IMemoryCache cache,          IRouter target)     {         if (string.IsNullOrWhiteSpace(controller))             throw new ArgumentNullException("controller");         if (string.IsNullOrWhiteSpace(action))             throw new ArgumentNullException("action");         if (dataProvider == null)             throw new ArgumentNullException("dataProvider");         if (cache == null)             throw new ArgumentNullException("cache");         if (target == null)             throw new ArgumentNullException("target");          _controller = controller;         _action = action;         _dataProvider = dataProvider;         _cache = cache;         _target = target;          // Set Defaults         CacheTimeoutInSeconds = 900;         _cacheKey = "__" + this.GetType().Name + "_GetPageList_" + _controller + "_" + _action;     }      public int CacheTimeoutInSeconds { get; set; }      public async Task RouteAsync(RouteContext context)     {         var requestPath = context.HttpContext.Request.Path.Value;          if (!string.IsNullOrEmpty(requestPath) && requestPath[0] == '/')         {             // Trim the leading slash             requestPath = requestPath.Substring(1);         }          // Get the page id that matches.         TPrimaryKey id;          //If this returns false, that means the URI did not match         if (!GetPageList().TryGetValue(requestPath, out id))         {             return;         }          //Invoke MVC controller/action         var routeData = context.RouteData;          // TODO: You might want to use the page object (from the database) to         // get both the controller and action, and possibly even an area.         // Alternatively, you could create a route for each table and hard-code         // this information.         routeData.Values["controller"] = _controller;         routeData.Values["action"] = _action;          // This will be the primary key of the database row.         // It might be an integer or a GUID.         routeData.Values["id"] = id;          await _target.RouteAsync(context);     }      public VirtualPathData GetVirtualPath(VirtualPathContext context)     {         VirtualPathData result = null;         string virtualPath;          if (TryFindMatch(GetPageList(), context.Values, out virtualPath))         {             result = new VirtualPathData(this, virtualPath);         }          return result;     }      private bool TryFindMatch(IDictionary<string, TPrimaryKey> pages, IDictionary<string, object> values, out string virtualPath)     {         virtualPath = string.Empty;         TPrimaryKey id;         object idObj;         object controller;         object action;          if (!values.TryGetValue("id", out idObj))         {             return false;         }          id = SafeConvert<TPrimaryKey>(idObj);         values.TryGetValue("controller", out controller);         values.TryGetValue("action", out action);          // The logic here should be the inverse of the logic in          // RouteAsync(). So, we match the same controller, action, and id.         // If we had additional route values there, we would take them all          // into consideration during this step.         if (action.Equals(_action) && controller.Equals(_controller))         {             // The 'OrDefault' case returns the default value of the type you're              // iterating over. For value types, it will be a new instance of that type.              // Since KeyValuePair<TKey, TValue> is a value type (i.e. a struct),              // the 'OrDefault' case will not result in a null-reference exception.              // Since TKey here is string, the .Key of that new instance will be null.             virtualPath = pages.FirstOrDefault(x => x.Value.Equals(id)).Key;             if (!string.IsNullOrEmpty(virtualPath))             {                 return true;             }         }         return false;     }      private IDictionary<string, TPrimaryKey> GetPageList()     {         IDictionary<string, TPrimaryKey> pages;          if (!_cache.TryGetValue(_cacheKey, out pages))         {             // Only allow one thread to poplate the data             lock (_lock)             {                 if (!_cache.TryGetValue(_cacheKey, out pages))                 {                     pages = _dataProvider.GetPageToIdMap();                      _cache.Set(_cacheKey, pages,                         new MemoryCacheEntryOptions()                         {                             Priority = CacheItemPriority.NeverRemove,                             AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(this.CacheTimeoutInSeconds)                         });                 }             }         }          return pages;     }      private static T SafeConvert<T>(object obj)     {         if (typeof(T).Equals(typeof(Guid)))         {             if (obj.GetType() == typeof(string))             {                 return (T)(object)new Guid(obj.ToString());             }             return (T)(object)Guid.Empty;         }         return (T)Convert.ChangeType(obj, typeof(T));     } } 

CmsCachedRouteDataProvider

This is the implementation of the data provider that is basically what you need to do in your CMS.

public interface ICachedRouteDataProvider<TPrimaryKey> {     IDictionary<string, TPrimaryKey> GetPageToIdMap(); }  public class CmsCachedRouteDataProvider : ICachedRouteDataProvider<int> {     public IDictionary<string, int> GetPageToIdMap()     {         // Lookup the pages in DB         return (from page in DbContext.Pages                 select new KeyValuePair<string, int>(                     page.Url.TrimStart('/').TrimEnd('/'),                     page.Id)                 ).ToDictionary(pair => pair.Key, pair => pair.Value);     } } 

Usage

And here we add the route before the default route, and configure its options.

// Add MVC to the request pipeline. app.UseMvc(routes => {     routes.Routes.Add(         new CachedRoute<int>(             controller: "Cms",             action: "Index",             dataProvider: new CmsCachedRouteDataProvider(),              cache: routes.ServiceProvider.GetService<IMemoryCache>(),              target: routes.DefaultHandler)         {             CacheTimeoutInSeconds = 900         });      routes.MapRoute(         name: "default",         template: "{controller=Home}/{action=Index}/{id?}");      // Uncomment the following line to add a route for porting Web API 2 controllers.     // routes.MapWebApiRoute("DefaultApi", "api/{controller}/{id?}"); }); 

That's the gist of it. You could still improve things a bit.

I would personally use a factory pattern and inject the repository into the constructor of CmsCachedRouteDataProvider rather than hard coding DbContext everywhere, for example.

like image 83
NightOwl888 Avatar answered Nov 12 '22 03:11

NightOwl888