Update: I’ve added automatic constraints to this.
When you’re working with ASP.Net MVC, routing is a very important thing to get just right. Unfortunately, the default routing options are pretty fragile. By default you have to resort to ‘magic strings’, anonymous types and/or string-based dictionaries etc. Depending on your requirements, this kind of routing can become quite the PITA.
Looking at my personal requirements: I have about 120 routes to map (including localization and a fall-back to managed content in the database). One day I got so fed up with the default routing options, that I decided to go for my own MapRoute()
extension method.
Here’s just a fraction of my current RegisterRoutes()
method (I’ve removed localization, for simplicity):
routes.MapRoute<ProductsController>("search/{query}/{pageIndex}", c => c.Search("", 0)); routes.MapRoute<ProductsController>("new/{pageIndex}", c => c.NewProducts(0)); routes.MapRoute<ProductsController>("soon/{pageIndex}", c => c.ComingProducts(0));
Before my changes it looked like this (again, without localization; with localization, there would be 3 more per route):
routes.MapRoute("Search", "search/{pageIndex}", new { controller = "Products", action = "Search", pageIndex = 0 }); routes.MapRoute("newproducts", "new/{pageIndex}", new { controller = "Products", action = "NewProducts", pageIndex = 0 }); routes.MapRoute("soonproducts", "soon/{pageIndex}", new { controller = "Products", action = "ComingProducts", pageIndex = 0 });
Notice the difference?
Getting rid of magic strings was not my only motivation, though. I have chopped-up my application in different modules. The two main modules are the actual customer front-end and the administrative product/category/brand/news/foo/bar management pages. As it happens, both have a ProductsController
class… If you go for default routing, you’ll run in to issues: ASP.Net MVC won’t be able to distinguish the one class from the other and throw you a nice exception telling you to specify the namespaces to disambiguate the controllers. The default solution would be specifying the namespace using magic strings, yet again…
Here’s the extension method (the most extended overload):
public static void MapRoute<T>(this RouteCollection routes, string url, Expression<Func<T, ActionResult>> action, RouteValueDictionary defaults, RouteValueDictionary constraints) where T : Controller { if (routes == null) throw new ArgumentNullException("routes"); if (url == null) throw new ArgumentNullException("url"); if (action == null) throw new ArgumentNullException("action"); var methodCall = action.Body as MethodCallExpression; if (methodCall == null) throw new ArgumentException(string.Format("The action '{0}' does not represent a method call.", action), "action"); // merge action defaults with explicit defaults defaults = GetActionDefaults(methodCall, defaults); // verify constraints if(constraints != null && constraints.Any(c => !(c.Value is IRouteConstraint || c.Value is string))) throw new ArgumentException("The constraints dictionary contains illegal elements. Constraint values must be strings (regex) or IRouteConstraint implementations.", "constraints"); var route = new Route(url, new MvcRouteHandler()) { Defaults = new RouteValueDictionary(defaults), Constraints = constraints, DataTokens = new RouteValueDictionary { { NamespacesKey, new[] { typeof(T).Namespace } } } }; routes.Add(url, route); }
Here’s the GetActionDefaults
method that extracts the default values from the method signature:
private static RouteValueDictionary GetActionDefaults(MethodCallExpression call, RouteValueDictionary defaults) { var controllerName = call.Method.DeclaringType.Name; if (controllerName.EndsWith(ControllerSuffix, StringComparison.OrdinalIgnoreCase)) controllerName = controllerName.Remove(controllerName.Length - ControllerSuffix.Length, ControllerSuffix.Length); defaults.Add(ControllerKey, controllerName); var attributes = call.Method.GetCustomAttributes(typeof(ActionNameAttribute), true); defaults.Add(ActionKey, attributes.Length == 1 ? ((ActionNameAttribute) attributes[0]).Name : call.Method.Name); var parameters = call.Method.GetParameters(); for (var i = 0; i < parameters.Length; i++) { var expression = call.Arguments[i] as ConstantExpression; if (expression != null) defaults.Add(parameters[i].Name, expression.Value); } return defaults; }
Result:
- Who needed that route name anyway?
- No more magic string (except fot the url)
- Namespaces are registered automatically, modules/areas no longer require special treatment
- Defaults are resolved from the action expression (additional defaults are still suppported)
- Constraints are checked as soon as the route is mapped (as good as it gets, I suppose)
- If you’re happy with ‘http://domain.com/{cultureName}/…’ url’s, you’re good to go as well, just pass along an array of supported culture names:
public static void MapRoute<T>(this RouteCollection routes, string url, Expression<Func<T, ActionResult>> action, string[] cultureNames, RouteValueDictionary defaults, RouteValueDictionary constraints) where T : Controller { routes.MapRoute(url, action, new RouteValueDictionary(defaults), constraints); foreach (var cultureName in cultureNames) { var d = new RouteValueDictionary(defaults) {{"lang", cultureName}}; routes.MapRoute(string.Format("{0}/{1}", cultureName, url), action, d, constraints); } }
Image may be NSFW.
Clik here to view.
Clik here to view.
