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
.
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.
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).
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.
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.
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 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.
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)); } }
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); } }
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With