Change Route Collection of MVC6 After Startup

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:

  1. 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.
  2. 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.
  3. 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 target IRouter. 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 an IRouter 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



Leave a reply



Submit