Implementing a custom route priority order
Yesterday, I blogged about implementing MonoRail-style application areas in ASP.NET MVC. One of the goals was to have different controllers with the same name (in different namespaces, obviously), and enhance the routing system to cope with that. So, when generating outbound URLs (e.g. with Html.ActionLink()), it should “prioritize” entries matching the current controller’s namespace.
Fundamentally, this comes down to “can you implement a custom route priority order”? At first, I thought the answer was “no”: you can’t override RouteTable.Routes.GetVirtualPath(), so you’re stuck with its system of just starting from the top of the list and scanning downwards. That makes it hard to introduce any notion of context (e.g. current namespace context) into URL generation. I did come up with a solution but it was a bit nasty. I complained that the current routing system wasn’t extensible enough.
A cleaner solution
While driving to work this morning (yes, I should be working right now) it hit me: The current routing system *is *perfectly extensible enough. In fact, this is dead easy. All you need to do is create a “pseudo” route entry that sits at the top of the list, and directs the action from there. Sweet!
So, let’s define an abstract base class for a “custom priority order”:
public abstract class CustomPriorityOrder : RouteBase { protected abstract IEnumerable<routeBase> RoutesInPriorityOrder(RouteCollection routes, RequestContext requestContext, RouteValueDictionary values); public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values) { using (RouteTable.Routes.GetReadLock()) { foreach (var route in RoutesInPriorityOrder(RouteTable.Routes, requestContext, values)) { var vpd = route.GetVirtualPath(requestContext, values); if (vpd != null) return vpd; } } return null; // Didn't pick any entry, so revert to the traditional priority order } public override RouteData GetRouteData(HttpContextBase httpContext) { return null; } }
Now, you can implement your own subclass. For example, to give priority to route entries matching the current controller’s namespace:
private class PrioritizeRoutesMatchingCurrentRequestNamespace : CustomPriorityOrder { protected override IEnumerable<routeBase> RoutesInPriorityOrder(RouteCollection routes, RequestContext requestContext, RouteValueDictionary values) { IController currentController = GetCurrentController(requestContext); if (currentController != null) foreach (var route in routes.OfType<route>().Where(r => RouteMatchesControllerNamespace(r, currentController))) yield return route; } private IController GetCurrentController(RequestContext requestContext) { var controllerContext = requestContext as ControllerContext; return controllerContext == null ? null : controllerContext.Controller; } private bool RouteMatchesControllerNamespace(Route route, IController controller) { if (route.DataTokens != null) { var namespaces = route.DataTokens["namespaces"] as IEnumerable<string>; if (namespaces != null) foreach (string ns in namespaces) if (controller.GetType().FullName.StartsWith(ns + ".")) return true; } return false; } }
It’s still a moderately big hunk of code, but much tidier than before. Now, to use this, all you have to do is drop a PrioritizeRoutesMatchingCurrentRequestNamespace instance at the top of your route table, e.g.:
public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); // Here it is! routes.Add(new PrioritizeRoutesMatchingCurrentRequestNamespace()); routes.Add(new Route("blog/{controller}/{action}/{id}", new MvcRouteHandler()) { Defaults = new RouteValueDictionary(new { controller = "Home", action = "Index", id = "" }), DataTokens = new RouteValueDictionary(new { namespaces = new[] { "MyApp.Controllers.Blog" } }) }); routes.Add(new Route("calendar/{controller}/{action}/{id}", new MvcRouteHandler()) { Defaults = new RouteValueDictionary(new { controller = "Home", action = "Index", id = "" }), DataTokens = new RouteValueDictionary(new { namespaces = new[] { "MyApp.Controllers.Calendar" } }) }); }
The rest of your routing configuration is unchanged (you can now use standard Route objects everywhere else). I think this is a reasonably tidy way to implement a custom route priority order. However, I’m still not convinced that anyone really needs to do this. It’s just nice to know that you could if a really valid scenario emerged.
Well done to the System.Web.Routing designers for making extensibility work nicely