Custom routing for ASP .NET MVC

Custom routing for ASP .NET MVC

Those familiar with the MVC framework for ASP .NET will know that one of its primary features is the mapping of URLs to methods on controllers. For example, /Products/Find will cause the ProductsController to be created and have its Find method invoked. It is also possible to pass arguments to methods, for instance /Products/Load/53 would call the Load method of the ProductsController, supplying 53 as the argument.

Organising controllers

Whilst this allows the developer to structure their code better, keeping presentational logic in the view and application logic in the controller, it isn’t ideal. To continue the example, as the project grows it will provide an increasing amount of features related to products, all of which will be delivered by the ProductsController. As a result the code for searching for products will end up in the same class as that used to edit products, and so on.

Everyone has their own take on the MVC pattern and in the past I have tended to use one controller per use case. The use cases in question here are Find Product and Edit Product and as such their functionality would be provided by the FindProductController and EditProductController, rather than living together in a single ProductsController.

A simple way to implement this pattern is to keep the ProductsController and have it delegate all its work. For example, the Load method would simply create an instance of EditProductController and call its Load method, passing any arguments as well. Whilst this is feasible it is more of a workaround than a genuine solution. It would be far better to cut out the ProductsController altogether, and have methods on the two controllers be called directly. The routing engine in ASP .NET MVC is very flexible and, by developing a custom route, it is possible to do this.

Creating a custom route

It is the job of a route to take a URL and call the appropriate method of a controller. The default MVC route has a format of {controller}/{action}/{id}, however our use case route will use {useCaseNoun}/{useCaseVerb}/{action}/{id}. The key difference is that the controller token has been replaced with two new tokens, noun and verb. This will allow us to provide the following routes

URL Controller Action Behaviour
/Product/Find/Search FindProductController Search Execute a search for products and display the results
/Product/Find/Clear FindProductController Clear Reset all the fields of the search page
/Product/Find FindProductController [default] Execute the default controller method (more on this shortly)
/Product/Edit/Load/17 EditProductController Load(17) Load the product with ID 17 and display its data for editing
/Product/Edit/Save/23 EditProductController Save(23) Save the supplied data against the product with ID 23
/Product/Edit/Clear EditProductController Clear Clear out the edit page ready for entering a new product

A fringe benefit of adopting this strategy is that both controllers can have a method of the same name, e.g. Clear, but have the method perform a completely different task. With a single ProductsController there could only be one Clear method.

In order to register the custom route with the MVC framework, some changes need to be made to the Global.asax file. Its Application_Start method calls RegisterRoutes which, using the default MVC project template, will already set up the default route format of {controller}/{action}/{id}. To this method we need to add the following

routes.Add(new Route("{useCaseNoun}/{useCaseVerb}/{action}/{id}", new MvcRouteHandler())
{
Defaults = new RouteValueDictionary(new { action = "Index", id = "" }),
});

Note that the default action is Index so, in the case of the /Product/Find URL in the table above, this would map to the Index method of the FindProductController.

At this point we can use Phil Haack‘s Url Routing Debugger to test that our URLs are being correctly routed. To do so we get a reference to Phil’s RouteDebug.dll and add the following code after RegisterRoutes is called

RouteDebug.RouteDebugger.RewriteRoutesForTesting(RouteTable.Routes);

It is then possible to enter each of URLs into a browser and see which route they match. Check out Phil’s post for further details.

Creating a handler for the route

The format of our route is such that the {controller} token is no longer present. As a result the MvcRouteHandler that is associated with the route will not be able to identify which controller to use. Typically it just extracts the value of the controller token, appends “Controller” to it, and instantiates an object of that type. To resolve this issue we need to replace MvcRouteHandler with a route handler of our own.

Fredrik Normén produced an excellent blog post, Create your own IRouteHandler, which describes how to do this. For our route, we need to create two new classes, the first of which implements IRouteHandler, as shown below

public class UseCaseRouteHandler : IRouteHandler
{
    public IHttpHandler GetHttpHandler(RequestContext requestContext)
    {
        return new UseCaseMvcHandler(requestContext);
    }
}

This class, UseCaseRouteHandler, is used in place of MvcRouteHandler, and simply creates a new IHttpHandler which will do the real work. The implementation of IHttpHandler is actually our second class, UseCaseMvcHandler. This inherits from MvcHandler and overrides the ProcessRequest method, during which the correct controller is identified and then created. It is this behaviour that we need to redefine.

To determine how our ProcessRequest should work, I downloaded the source code of the MVC framework itself, which is available from CodePlex. A quick inspection of MvcHandler‘s ProcessRequest shows that the GetRequiredString method is used to extract the values of the route’s tokens. For the default routing this is just a case of getting the controller name, whereas our custom route needs to grab both the {useCaseNoun} and {useCaseVerb} tokens. I moved this logic into a separate function, GetControllerName, which is shown below

private string GetControllerName()
{
    string noun = this.RequestContext.RouteData.GetRequiredString("useCaseNoun");
    string verb = this.RequestContext.RouteData.GetRequiredString("useCaseVerb");
    return verb + noun;
}

So, if the URL is /Product/Find/Search, this method will extract a noun of “Product”, a verb of “Find” and return the value “FindProduct”.

I then copied MvcHandler’s ProcessRequest code into UseCaseMvcHandler and replaced the line extracting the controller token value with a call to the GetControllerName function. Simple. Well, almost! Unfortunately the resource strings are not available to inheriting classes, and neither is the ControllerBuilder property. I replaced the former with a hard-wired string, whilst the latter is accessible via the ControllerBuilder class’ static Current property.

At this point the code is almost ready to run. We just need to adjust the code in RegisterRoutes so that our route uses the new UseCaseRouteHandler class. This is done as follows

routes.Add(new Route("{useCaseNoun}/{useCaseVerb}/{action}/{id}", new UseCaseRouteHandler())
{
Defaults = new RouteValueDictionary(new { action = "Index", id = "" }),
});

Identifying which view to show

Having commented out the call to the routing debugger, I then browsed to /Product/Edit/Load/17 and…BANG! An exception with the message,

The RouteData must contain an item named ‘controller’ with a non-empty string value

was shown. After some digging through the MVC source, it seems that the code responsible for identifying which view to create (the ViewEngine class does this) was also trying to find a controller token in the URL, in order to work out which subfolder of Views to look in. The Load method of EditProductController calls RenderView, passing “Edit” as the viewName argument. By altering this to “~/Views/Product/Edit.aspx” I was able to work around this issue.

This was a far from satisfactory solution however. Fully-qualifying all of the view names is a potential maintenance problem in the future, if views are moved or folders renamed. To combat this I introduced a UseCaseControllerBase class, from which EditProductController and FindProductController now inherit. This class overrides RenderView and works out the full path to the view. The following code shows how

public abstract class UseCaseControllerBase : Controller
{
    protected override void RenderView(string viewName, string masterName, object viewData)
    {
        string noun = this.RouteData.GetRequiredString("useCaseNoun");
        string fullViewName = string.Format("~/Views/{0}/{1}.aspx", noun, viewName);
        base.RenderView(fullViewName, masterName, viewData);
    }
}

The ideal resolution would be to customise the behaviour of the ViewEngine, however that is beyond the scope of this article.

This post demonstrates the flexibility of the routing subsystem provided by ASP .NET. It also shows how to improve the separation of functionality between controllers. If you are interested the sample code is available from CodePlex.

Technorati tags:

Tags: , ,

9 Responses to “Custom routing for ASP .NET MVC”

  1. Ross says:

    Nice. Will be giving this a blast.

  2. Adam Tibi says:

    Hi

    I know that you have written this a long ago, but it is still worth mentioning how to improve it.

    I intially started by following your steps, but I noticed that this could be even simpler by ONLY creating a custom RouteHandler using the following code:

    public class CustomRouteHandler : IRouteHandler {

    public IHttpHandler GetHttpHandler(RequestContext requestContext) {
    RouteData routeData = requestContext.RouteData;
    routeData.Values.Add(“controller”, GetControllerName(routeData));
    return new MvcHandler(requestContext);
    }

    }

    And use the following with your RouteMap ( on application start)

    routes.Add(
    “Custom”,
    new Route (
    “{useCaseNoun}/{useCaseVerb}/{action}/{id}”,
    new RouteValueDictionary(new {action = “Index”, Id = “”}),
    new CustomRouteHandler()
    )
    );

  3. Hi Adam, thanks for your reply, it’s always good when people suggest a better way and this certainly looks a lot simpler than my initial version. Good work!

  4. [...] Custom Routing for ASP.NET MVC [...]

  5. Register routes by using attributes on controller methods.

    http://maproutes.codeplex.com/

    Example:

    [Route("product/{name}")]
    [RouteDefault("name", "")]
    [RouteConstraint("name", "^[0-9]+_”)]
    public ActionResult Product(string name)
    {
    return View();
    }

  6. Mike says:

    There is another good way of routing customization that allows to pass Controller/Action/View names inside JSON data
    http://vitana-group.com/article/microsoft-.net/route-handling

  7. .net says:

    I delight in, result in I discovered just what I was looking for. You have ended my 4 day lengthy hunt! God Bless you man. Have a great day. Bye

Leave a Reply