Change route collection of MVC6 after startup
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.
When and how are route tables created and entries in the route table made?
The route table is populated ("created") at application startup. Although this collection technically can be modified after startup with MVC 5 and prior, this approach goes against the grain of dependency injection best practices.
Although the route table is static at runtime, the routes that populate the table can be made dynamic through route constraints or by subclassing RouteBase
in MVC 5 and below or implementing IRouter
in MVC Core.
C# MVC Routing With RouteBase
There are several problems with your approach:
- Never extend
MvcRouteHandler
to make a custom URL scheme. URL routing is a 2-way process and route handlers can only handle the incoming routes but do not build the outgoing URLs. - You are not actually "routing" here. Routing implies that you are mapping the incoming request to a resource. What you are doing is allowing the request to come in, deciding what to do with it, then redirecting the browser to another URL. This causes another unnecessary round trip to the server, which is bad for performance and SEO.
- The OutputCache attribute only works on controller action methods, and it only applies to caching content of the views. It does not apply to caching data.
If you want to have database driven routing, you should subclass RouteBase
. This gives you the opportunity to map the URL to a set of route values (which represents a controller action and parameters) and also map route values back to a URL (so ActionLink
and RouteLink
will work correctly in your views).
Have a look at this answer for a reliable approach. It also includes a cache for the URL data and thread locking to ensure the cache is only updated by a single thread (and database only called once) when the cache expires.
If you need to make it more reusable (that is, work with more controller and action methods), you could make it more generic similar to this MVC 6 sample by passing the controller and action information and a data provider instance through the constructor of your custom RouteBase
and registering the route more than one time in your configuration (with different parameters, of course).
Imlementing a Custom IRouter in ASP.NET 5 (vNext) MVC 6
As @opiants said, the problem is that you are doing nothing in your RouteAsync
method.
If your intention is to end up calling a controller action method, you could use the following approach than the default MVC routes:
By default MVC uses a
TemplateRoute
with an inner targetIRouter
. In RouteAsync, the TemplateRoute will
delegate to the inner IRouter. This inner router is being set as the
MvcRouteHandler
by the default builder
extensions.
In your case, start by adding anIRouter
as your inner target:
public class CustomRoute : ICustomRoute
{
private readonly IMemoryCache cache;
private readonly IRouter target;
private object synclock = new object();
public CustomRoute(IMemoryCache cache, IRouter target)
{
this.cache = cache;
this.target = target;
}
Then update your startup to set that target as the MvcRouteHandler
, which has already been set as routes.DefaultHandler
:
app.UseMvc(routes =>
{
routes.Routes.Add(
new CustomRoute(routes.ServiceProvider.GetRequiredService<IMemoryCache>(),
routes.DefaultHandler));
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?}");
});
Finally, update your AsyncRoute method to call the inner IRouter
, which would be the MvcRouteHandler
. You can use the implementation of that method in TemplateRoute
as a guide. I have quickly used this approach and modified your method as follows:
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 that matches.
var page = GetPageList()
.Where(x => x.VirtualPath.Equals(requestPath))
.FirstOrDefault();
// If we got back a null value set, that means the URI did not match
if (page == null)
{
return;
}
//Invoke MVC controller/action
var oldRouteData = context.RouteData;
var newRouteData = new RouteData(oldRouteData);
newRouteData.Routers.Add(this.target);
// 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.
newRouteData.Values["controller"] = "CustomPage";
newRouteData.Values["action"] = "Details";
// This will be the primary key of the database row.
// It might be an integer or a GUID.
newRouteData.Values["id"] = page.Id;
try
{
context.RouteData = newRouteData;
await this.target.RouteAsync(context);
}
finally
{
// Restore the original values to prevent polluting the route data.
if (!context.IsHandled)
{
context.RouteData = oldRouteData;
}
}
}
Update RC2
Looks like TemplateRoute
is no longer around in RC2 aspnet Routing.
I investigated the history, and it was renamed RouteBase
in commit 36180ab as part of a bigger refactoring.
ASP MVC Core and runtime generated assemblies
I add my own IActionDescriptorChangeProvider
internal class ModuleActionDescriptorChangeProvider : IActionDescriptorChangeProvider
{
internal static ModuleActionDescriptorChangeProvider Instance { get; } = new ModuleActionDescriptorChangeProvider();
internal CancellationTokenSource TokenSource { get; private set; }
public IChangeToken GetChangeToken()
{
TokenSource = new CancellationTokenSource();
return new CancellationChangeToken(TokenSource.Token);
}
}
register in service collection
services.AddSingleton<IActionDescriptorChangeProvider>(ModuleActionDescriptorChangeProvider.Instance);
and then after assembly generated call
ModuleActionDescriptorChangeProvider.Instance.TokenSource.Cancel();
to signal invalidation of the cached collection of ActionDescriptor
Related Topics
Post Form Data Using Httpwebrequest
Parsing CSV Using Oledb Using C#
Cancelling a Pending Task Synchronously on the UI Thread
Mysqlcommand Command.Parameters.Add Is Obsolete
How to Get the Name of the Current Executable in C#
What's the Difference Between "Groups" and "Captures" in .Net Regular Expressions
How to Use the Linqpad Dump() Extension Method in Visual Studio
How to Implement a Read Only Property
Easiest Way to Read from a Url into a String in .Net
Make Listview.Scrollintoview Scroll the Item into the Center of the Listview (C#)
How to Change Symbol for Decimal Point in Double.Tostring()
How to Ensure a Form Displays on the "Additional" Monitor in a Dual Monitor Scenario
Using Static Variables Instead of Application State in ASP.NET
Outofmemoryexception While Populating Memorystream: 256Mb Allocation on 16Gb System