Site Meter
 
 

Monthly Archives: July 2008

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 :)

Partitioning an ASP.NET MVC application into separate "Areas"

UPDATE: This technique is improved in a subsequent post

MonoRail (an alternative .NET MVC web app framework) has a concept of “application areas”, which is a way of grouping related controllers together. For example, you might have a “Blog” area (including a bunch of controller classes related to blogging) and a “Calendar” area (another bunch of related controllers). It’s a nice way of splitting a huge project into a smaller set of manageable ones.

If we had this feature in ASP.NET MVC, we might put controller classes into namespaces that represent area, e.g.:

  • MyApp.Controllers.Blog.HomeController
  • MyApp.Controllers.Blog.AdminController
  • MyApp.Controllers.Calendar.HomeController
  • MyApp.Controllers.Calendar.AdminController
  • MyApp.Controllers.Calendar.RssController

… and also put view templates into corresponding subfolders, e.g.:

  • /Views/Blog/Home/Index.aspx
  • /Views/Blog/Home/Post.aspx
  • /Views/Blog/Admin/RecentComments.aspx
  • /Views/Calendar/Home/Index.aspx
  • /Views/Calendar/Home/ByMonth.aspx
  • /Views/Calendar/Admin/Permissions.aspx

… etc. However, as of Preview 4, ASP.NET MVC doesn’t yet have any such concept of “areas”. Fortunately, the MVC framework is very well designed for extensibility. So, how far can we get with implementing our own notion of “areas”?

A naive solution

Since preview 4, it’s been possible to have multiple controllers with the same name, as long as they’re in different namespaces. To tell the routing system which one to use for a given request, you can put a “namespaces” data token on each route entry, e.g.:

routes.Add(new NamespaceAwareRoute("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 NamespaceAwareRoute("calendar/{controller}/{action}/{id}", new MvcRouteHandler())
{
    Defaults = new RouteValueDictionary(new { controller = "Home", action = "Index", id = "" }),
    DataTokens = new RouteValueDictionary(new { namespaces = new[] { "MyApp.Controllers.Calendar" } })
});

Great! So now, requests for /Blog/* will go to the controllers in MyApp.Controllers.Blog, while requests for /Calendar/* will go to the controllers in MyApp.Controllers.Calendar. How very lovely. We’ll get to the problem with this in a moment.

Putting view templates into subfolders per namespace

Next, to implement the namespaced view folders convention (i.e. finding views at /Views/(Namespace)/Controller/ViewName.aspx), we can implement a custom IViewLocator. To do this, how about an [ActionFilter] attribute that detects when you’re about to render a view, and jumps in to alter the set of directories that are searched for the view template?

public class GetViewsFromNamespaceFolderAttribute : ActionFilterAttribute
{
    public override void OnResultExecuting(ResultExecutingContext filterContext)
    {
        ViewResult result = filterContext.Result as ViewResult;
        Controller controller = filterContext.Controller as Controller;
        if ((result != null) && (controller != null))
        {
            WebFormViewEngine viewEngine = controller.ViewEngine as WebFormViewEngine;
            if (viewEngine != null)
                viewEngine.ViewLocator = new NamespaceAwareViewLocator(GetLastTokenFromControllerNamespace(controller));
        }
    }
 
    private string GetLastTokenFromControllerNamespace(Controller controller)
    {
        string[] tokens = controller.GetType().FullName.Split('.');
        if (tokens.Length > 1)
            return tokens[tokens.Length - 2];
        else
            return "";
    }
 
    public class NamespaceAwareViewLocator : ViewLocator
    {
        public NamespaceAwareViewLocator(string ns)
        {
            ViewLocationFormats = new[] {
                "~/Views/"+ns+"/{1}/{0}.aspx", "~/Views/"+ns+"/{1}/{0}.ascx",
                "~/Views/"+ns+"/Shared/{0}.aspx", "~/Views/"+ns+"/Shared/{0}.ascx",
                "~/Views/Shared/{0}.aspx", "~/Views/Shared/{0}.ascx"
            };
            MasterLocationFormats = new[] {
                "~/Views/"+ns+"/{1}/{0}.master",
                "~/Views/"+ns+"/Shared/{0}.master",
                "~/Views/Shared/{0}.master"
            };
        }
    }
}

With this defined, you just need to tag your controller class with the attribute,

namespace MyApp.Controllers.Calendar
{
    [GetViewsFromNamespaceFolder]
    public class HomeController : Controller
    {
        public ViewResult Index()
        {
            return View();
        }
    }
}

… and it will indeed find its view templates in /Views/Calendar/Home. So, we’re done, right?

Not so fast

The problem with having multiple controllers with the same name is that, even though ASP.NET MVC understands it, the core routing system does not. System.Web.Routing has no awareness of the “namespaces” DataToken value, so when it comes to generating outbound URLs, namespaces are simply ignored.

In other words, if you generate a URL with <%= Html.ActionLink(“Click me”, “MyAction”, “Home” ) %>, or <%= Url.Action(“MyAction”) %>, or return RedirectToAction(…),  the routing system pays no heed to namespaces, either those defined in the routing configuration, or the namespace of the controller you’re currently on, and it just returns the first URL that matches the given controller/action name. There’s no way to tell it to try to keep on the ‘active’ namespace, nor even to make it filter the set of acceptable route entries by namespace.

A few of us had a discussion about this on the ASP.NET MVC forums, and the consensus was “you’ll just have to use named routes, all the time.” Personally, I think that would be pretty lame. Giving names to routes is a bit of a crutch really, it’s not far from being a dirty hack – one of the key selling points of System.Web.Routing is that it should let you decouple the concern of routing from the concern of creating links and redirections. Named routes mangle these concerns together. (If you personally prefer to give names to your routes, (“Fluffy”, “Snowy”, and “Bunnykins” are nice names) then don’t take offence – you can do whatever you like.)

Solving the outbound routing problem

So, what’s to be done? One option is to create a custom Route subclass that’s aware of DataTokens["namespaces"], and somehow gives priority to Route entries that match the namespace of the “current” request. That’s pretty tricky: you can only control the behavior of one Route entry at a time – you can’t control them as a group – so it’s hard to make them co-operate to change the normal rules of priority. However, if you’re willing to write some slightly odd-looking code, you can do it.

The idea with NamespaceAwareRoute is to pre-scan the RouteTable.Routes collection to try to find a matching route that also matches the namespace of the current request. If we find one, use that as the result. If not, revert to the usual behavior for Route entries, setting some flag that says “don’t scan any more”. Watch out, this is quite a big hunk of code:

private class NamespaceAwareRoute : Route
{
    const string _scanKey = "__NamespaceAwareRouteHasScanned";
 
    public NamespaceAwareRoute(string url, IRouteHandler routeHandler) : base(url, routeHandler) { }
    public NamespaceAwareRoute(string url, RouteValueDictionary defaults, IRouteHandler routeHandler) : base(url, defaults, routeHandler) { }
    public NamespaceAwareRoute(string url, RouteValueDictionary defaults, RouteValueDictionary constraints, IRouteHandler routeHandler) : base(url, defaults, constraints, routeHandler) { }
    public NamespaceAwareRoute(string url, RouteValueDictionary defaults, RouteValueDictionary constraints, RouteValueDictionary dataTokens, IRouteHandler routeHandler) : base(url, defaults, constraints, dataTokens, routeHandler) { }
 
    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        string controller = GetCurrentControllerTypeName(requestContext);
        if ((controller != null) && !RouteMatchesControllerNamespace(this, controller))
        {
            // If we haven't already done a scan for these values, do one now
            if (!requestContext.HttpContext.Items.Contains(_scanKey + values.GetHashCode()))
            {
                var vpd = GetVirtualPathWhereNamespaceMatches(requestContext, values, controller);
                if (vpd != null)
                    return vpd;
                requestContext.HttpContext.Items[_scanKey + values.GetHashCode()] = "scanned";
            }
        }
 
        return base.GetVirtualPath(requestContext, values);
    }
 
    private VirtualPathData GetVirtualPathWhereNamespaceMatches(RequestContext requestContext, RouteValueDictionary values, string controller)
    {
        using (RouteTable.Routes.GetReadLock())
        {
            foreach (Route route in RouteTable.Routes
                                              .OfType<route>()
                                              .Where(r => RouteMatchesControllerNamespace(r, controller)))
            {
                var vpd = route.GetVirtualPath(requestContext, values);
                if(vpd != null)
                    return vpd;
            }
        }
        return null;
    }
 
    private string GetCurrentControllerTypeName(RequestContext ctx)
    {
        var controllerContext = ctx as ControllerContext;
        if (controllerContext == null)
            return null;
        else
            return controllerContext.Controller.GetType().FullName;
    }
    private bool RouteMatchesControllerNamespace(Route route, string controllerTypeName)
    {
        if (route.DataTokens != null)
        {
            var namespaces = route.DataTokens["namespaces"] as IEnumerable<string>;
            if (namespaces != null)
                foreach (string ns in namespaces)
                    if (controllerTypeName.StartsWith(ns + "."))
                        return true;
        }
        return false;
    }
}

It’s easy to use NamespaceAwareRoute. Just replace your Route objects with NamespaceAwareRoute objects. It doesn’t change the API in any way. (Unfortunately you can’t use routes.MapRoute(…) with this, because that’s hard-coded to add Route objects.) Other than that, you’ll find that outbound URL generation starts to prefer route entries that match the namespace of the “current” controller (when we can identify it).

So, what do you think? Is this actually useful? Would you really want to partition your app into “areas” like this? Are there any more “gotchas” I haven’t considered? Note that this code is proof-of-concept, not polished and load-tested. Use it at your own risk.

Overriding IIS6 wildcard maps on individual directories

Many thanks to Duncan Smart whose comment on my previous post about deploying ASP.NET MVC applications to IIS 6 gives us a further option. It turns out that even though IIS Manager only lets you configure wildcard maps on a per-application level, IIS itself allows you to configure them on a per-directory level.

Recap: The goal here is to deploy ASP.NET MVC applications to IIS 6, keeping the clean, extensionless URLs (which requires a wildcard map so that all URLs are processed by ASP.NET, or some tricky URL-rewriting), but without incuring the performance penalty of letting static files get processed by ASP.NET. See the previous post for more details.

So, if you can be disciplined and keep all your static content inside your /Content folder, you can use a normal wildcard map at the root level to get ASP.NET MVC to handle all incoming URLs, even without any “filename extensions” in the URLs, but then also disable that wildcard map on the /Content folder and below, allowing those static files to be processed natively by IIS (which performs much better).

How to set it up

First, deploy your application and use a wilcard map as explained before. Next, find out the “identifier” of your application by looking at IIS Manager:

image

Now, to remove the wildcard map on the /Content subdirectory, open a command prompt, go to c:\Inetpub\AdminScripts, and run:

adsutil.vbs SET /W3SVC/105364569/root/Content/ScriptMaps ""

… replacing 105364569 with the “identifier” number of your application. (Also, you could replace “Content” with the path to any other directory.)

That does it! Your /Content folder will now bypass the wildcard mapping, and its files will be served natively by IIS.

Alternative

If you don’t like to use adsutil.vbs, you can achieve the same by exploiting what appears to be a bug in IIS Manager. Turn your subdirectory into an application (from its Directory tab, click “Create”). Then edit its script mappings to remove aspnet_isapi.dll. Then go back and “Remove” the application you just created. The metabase’s new ScriptMaps value will be retained, even though the option has now disappeared from the GUI.