Asked  7 Months ago    Answers:  5   Viewed   43 times

From the www:

...The routing engine will take the first route that matches the supplied URL and attempt to use the route values in that route. Therefore, less common or more specialized routes should be added to the table first, while more general routes should be added later on...

Why should I map specialized routes first? Someone can give me an example please where I can see the failing of "map common route first" ?

 Answers

73

The routing engine will take the first route that matches the supplied URL and attempt to use the route values in that route.

The reason why this happens is because the RouteTable is used like a switch-case statement. Picture the following:

int caseSwitch = 1;
switch (caseSwitch)
{
    case 1:
        Console.WriteLine("Case 1");
        break;
    case 1:
        Console.WriteLine("Second Case 1");
        break;
    default:
        Console.WriteLine("Default case");
        break;
}

If caseSwitch is 1, the second block is never reached because the first block catches it.

Route classes follow a similar pattern (in both the GetRouteData and GetVirtualPath methods). They can return 2 states:

  1. A set of route values (or a VirtualPath object in the case of GetVirtualPath). This indicates the route matched the request.
  2. null. This indicates the route did not match the request.

In the first case, MVC uses the route values that are produced by the route to lookup the Action method. In this case, the RouteTable is not analyzed any further.

In the second case, MVC will check the next Route in the RouteTable to see if it matches with the request (the built in behavior matches the URL and constraints, but technically you can match anything in the HTTP request). And once again, that route can return a set of RouteValues or null depending on the result.

If you try to use a switch-case statement as above, the program won't compile. However, if you configure a route that never returns null or returns a RouteValues object in more cases than it should, the program will compile, but will misbehave.

Misconfiguration Example

Here is the classic example that I frequently see posted on StackOverflow (or some variant of it):

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            name: "CustomRoute",
            url: "{segment1}/{action}/{id}",
            defaults: new { controller = "MyController", action = "Index", id = UrlParameter.Optional }
        );

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

In this example:

  1. CustomRoute will match any URL that is either 1, 2, or 3 segments in length (note that segment1 is required because it has no default value).
  2. Default will match any URL that is 0, 1, 2, or 3 segments in length.

Therefore, if the application is passed the URL HomeAbout, the CustomRoute will match, and supply the following RouteValues to MVC:

  1. segment1 = "Home"
  2. controller = "MyController"
  3. action = "About"
  4. id = {}

This will make MVC look for an action named About on a controller named MyControllerController, which will fail if it doesn't exist. The Default route is an unreachable execution path in this case because even though it will match a 2-segment URL, the framework will not give it the opportunity to because the first match wins.

Fixing the Configuration

There are several options on how to proceed to fix the configuration. But all of them depend on the behavior that the first match wins and then routing won't look any further.

Option 1: Add one or more Literal Segments

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            name: "CustomRoute",
            url: "Custom/{action}/{id}",

            // Note, leaving `action` and `id` out of the defaults
            // makes them required, so the URL will only match if 3
            // segments are supplied begining with Custom or custom.
            // Example: Custom/Details/343
            defaults: new { controller = "MyController" }
        );

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

Option 2: Add 1 or more RegEx Constraints

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            name: "CustomRoute",
            url: "{segment1}/{action}/{id}",
            defaults: new { controller = "MyController", action = "Index", id = UrlParameter.Optional },
            constraints: new { segment1 = @"house|car|bus" }
        );

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

Option 3: Add 1 or more Custom Constraints

public class CorrectDateConstraint : IRouteConstraint
{
    public bool Match(HttpContextBase httpContext, Route route, string parameterName, RouteValueDictionary values, RouteDirection routeDirection)
    {
        var year = values["year"] as string;
        var month = values["month"] as string;
        var day = values["day"] as string;

        DateTime theDate;
        return DateTime.TryParse(year + "-" + month + "-" + day, System.Globalization.CultureInfo.InvariantCulture, DateTimeStyles.None, out theDate);
    }
}

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            name: "CustomRoute",
            url: "{year}/{month}/{day}/{article}",
            defaults: new { controller = "News", action = "ArticleDetails" },
            constraints: new { year = new CorrectDateConstraint() }
        );

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

Option 4: Make Required Segments + Make the Number of Segments not Match Existing Routes

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.MapRoute(
            name: "CustomRoute",
            url: "{segment1}/{segment2}/{action}/{id}",
            defaults: new { controller = "MyController" }
        );

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

In the above case, the CustomRoute will only match a URL with 4 segments (note these can be any values). The Default route as before only matches URLs with 0, 1, 2, or 3 segments. Therefore there is no unreachable execution path.

Option 5: Implement RouteBase (or Route) for Custom Behavior

Anything that routing doesn't support out of the box (such as matching on a specific domain or subdomain) can be done by implementing your own RouteBase subclass or Route subclass. It is also the best way to understand how/why routing works the way it does.

public class SubdomainRoute : Route
{
    public SubdomainRoute(string url) : base(url, new MvcRouteHandler()) {}

    public override RouteData GetRouteData(HttpContextBase httpContext)
    {
        var routeData = base.GetRouteData(httpContext);
        if (routeData == null) return null; // Only look at the subdomain if this route matches in the first place.
        string subdomain = httpContext.Request.Params["subdomain"]; // A subdomain specified as a query parameter takes precedence over the hostname.
        if (subdomain == null) {
            string host = httpContext.Request.Headers["Host"];
            int index = host.IndexOf('.');
            if (index >= 0)
                subdomain = host.Substring(0, index);
        }
        if (subdomain != null)
            routeData.Values["subdomain"] = subdomain;
        return routeData;
    }

    public override VirtualPathData GetVirtualPath(RequestContext requestContext, RouteValueDictionary values)
    {
        object subdomainParam = requestContext.HttpContext.Request.Params["subdomain"];
        if (subdomainParam != null)
            values["subdomain"] = subdomainParam;
        return base.GetVirtualPath(requestContext, values);
    }
}

This class was borrowed from: Is it possible to make an ASP.NET MVC route based on a subdomain?

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        routes.Add(new SubdomainRoute(url: "somewhere/unique"));

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}

NOTE: The real gotcha here is that most people assume that their routes should all look like the Default route. Copy, paste, done, right? Wrong.

There are 2 problems that commonly arise with this approach:

  1. Pretty much every other route should have at least one literal segment (or a constraint if you are into that sort of thing).
  2. The most logical behavior is usually to make the rest of the routes have required segments.

Another common misconception is that optional segments mean you can leave out any segment, but in reality you can only leave off the right-most segment or segments.

Microsoft succeeded in making routing convention-based, extensible, and powerful. They failed in making it intuitive to understand. Virtually everyone fails the first time they try it (I know I did!). Fortunately, once you understand how it works it is not very difficult.

Tuesday, June 1, 2021
 
mistero
answered 7 Months ago
83

There are several issues with this approach, but it boils down to being a workflow issue.

  1. You have a CultureController whose only purpose is to redirect the user to another page on the site. Keep in mind RedirectToAction will send an HTTP 302 response to the user's browser, which will tell it to lookup the new location on your server. This is an unnecessary round-trip across the network.
  2. You are using session state to store the culture of the user when it is already available in the URL. Session state is totally unnecessary in this case.
  3. You are reading the HttpContext.Current.Request.UserLanguages from the user, which might be different from the culture they requested in the URL.

The third issue is primarily because of a fundamentally different view between Microsoft and Google about how to handle globalization.

Microsoft's (original) view was that the same URL should be used for every culture and that the UserLanguages of the browser should determine what language the website should display.

Google's view is that every culture should be hosted on a different URL. This makes more sense if you think about it. It is desirable for every person who finds your website in the search results (SERPs) to be able to search for the content in their native language.

Globalization of a web site should be viewed as content rather than personalization - you are broadcasting a culture to a group of people, not an individual person. Therefore, it typically doesn't make sense to use any personalization features of ASP.NET such as session state or cookies to implement globalization - these features prevent search engines from indexing the content of your localized pages.

If you can send the user to a different culture simply by routing them to a new URL, there is far less to worry about - you don't need a separate page for the user to select their culture, simply include a link in the header or footer to change the culture of the existing page and then all of the links will automatically switch to the culture the user has chosen (because MVC automatically reuses route values from the current request).

Fixing the Issues

First of all, get rid of the CultureController and the code in the Application_AcquireRequestState method.

CultureFilter

Now, since culture is a cross-cutting concern, setting the culture of the current thread should be done in an IAuthorizationFilter. This ensures the culture is set before the ModelBinder is used in MVC.

using System.Globalization;
using System.Threading;
using System.Web.Mvc;

public class CultureFilter : IAuthorizationFilter
{
    private readonly string defaultCulture;

    public CultureFilter(string defaultCulture)
    {
        this.defaultCulture = defaultCulture;
    }

    public void OnAuthorization(AuthorizationContext filterContext)
    {
        var values = filterContext.RouteData.Values;

        string culture = (string)values["culture"] ?? this.defaultCulture;

        CultureInfo ci = new CultureInfo(culture);

        Thread.CurrentThread.CurrentCulture = ci;
        Thread.CurrentThread.CurrentUICulture = CultureInfo.CreateSpecificCulture(ci.Name);
    }
}

You can set the filter globally by registering it as a global filter.

public class FilterConfig
{
    public static void RegisterGlobalFilters(GlobalFilterCollection filters)
    {
        filters.Add(new CultureFilter(defaultCulture: "nl"));
        filters.Add(new HandleErrorAttribute());
    }
}

Language Selection

You can simplify the language selection by linking to the same action and controller for the current page and including it as an option in the page header or footer in your _Layout.cshtml.

@{ 
    var routeValues = this.ViewContext.RouteData.Values;
    var controller = routeValues["controller"] as string;
    var action = routeValues["action"] as string;
}
<ul>
    <li>@Html.ActionLink("Nederlands", @action, @controller, new { culture = "nl" }, new { rel = "alternate", hreflang = "nl" })</li>
    <li>@Html.ActionLink("English", @action, @controller, new { culture = "en" }, new { rel = "alternate", hreflang = "en" })</li>
</ul>

As mentioned previously, all other links on the page will automatically be passed a culture from the current context, so they will automatically stay within the same culture. There is no reason to pass the culture explicitly in those cases.

@ActionLink("About", "About", "Home")

With the above link, if the current URL is /Home/Contact, the link that is generated will be /Home/About. If the current URL is /en/Home/Contact, the link will be generated as /en/Home/About.

Default Culture

Finally, we get to the heart of your question. The reason your default culture is not being generated correctly is because routing is a 2-way map and regardless of whether you are matching an incoming request or generating an outgoing URL, the first match always wins. When building your URL, the first match is DefaultWithCulture.

Normally, you can fix this simply by reversing the order of the routes. However, in your case that would cause the incoming routes to fail.

So, the simplest option in your case is to build a custom route constraint to handle the special case of the default culture when generating the URL. You simply return false when the default culture is supplied and it will cause the .NET routing framework to skip the DefaultWithCulture route and move to the next registered route (in this case Default).

using System.Text.RegularExpressions;
using System.Web;
using System.Web.Routing;

public class CultureConstraint : IRouteConstraint
{
    private readonly string defaultCulture;
    private readonly string pattern;

    public CultureConstraint(string defaultCulture, string pattern)
    {
        this.defaultCulture = defaultCulture;
        this.pattern = pattern;
    }

    public bool Match(
        HttpContextBase httpContext, 
        Route route, 
        string parameterName, 
        RouteValueDictionary values, 
        RouteDirection routeDirection)
    {
        if (routeDirection == RouteDirection.UrlGeneration && 
            this.defaultCulture.Equals(values[parameterName]))
        {
            return false;
        }
        else
        {
            return Regex.IsMatch((string)values[parameterName], "^" + pattern + "$");
        }
    }
}

All that is left is to add the constraint to your routing configuration. You also should remove the default setting for culture in the DefaultWithCulture route since you only want it to match when there is a culture supplied in the URL anyway. The Default route on the other hand should have a culture because there is no way to pass it through the URL.

routes.LowercaseUrls = true;

routes.MapRoute(
  name: "Errors",
  url: "Error/{action}/{code}",
  defaults: new { controller = "Error", action = "Other", code = UrlParameter.Optional }
  );

routes.MapRoute(
  name: "DefaultWithCulture",
  url: "{culture}/{controller}/{action}/{id}",
  defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional },
  constraints: new { culture = new CultureConstraint(defaultCulture: "nl", pattern: "[a-z]{2}") }
  );

routes.MapRoute(
    name: "Default",
    url: "{controller}/{action}/{id}",
    defaults: new { culture = "nl", controller = "Home", action = "Index", id = UrlParameter.Optional }
);

AttributeRouting

NOTE: This section applies only if you are using MVC 5. You can skip this if you are using a previous version.

For AttributeRouting, you can simplify things by automating the creation of 2 different routes for each action. You need to tweak each route a little bit and add them to the same class structure that MapMvcAttributeRoutes uses. Unfortunately, Microsoft decided to make the types internal so it requires Reflection to instantiate and populate them.

RouteCollectionExtensions

Here we just use the built in functionality of MVC to scan our project and create a set of routes, then insert an additional route URL prefix for the culture and the CultureConstraint before adding the instances to our MVC RouteTable.

There is also a separate route that is created for resolving the URLs (the same way that AttributeRouting does it).

using System;
using System.Collections;
using System.Linq;
using System.Reflection;
using System.Web.Mvc;
using System.Web.Mvc.Routing;
using System.Web.Routing;

public static class RouteCollectionExtensions
{
    public static void MapLocalizedMvcAttributeRoutes(this RouteCollection routes, string urlPrefix, object constraints)
    {
        MapLocalizedMvcAttributeRoutes(routes, urlPrefix, new RouteValueDictionary(constraints));
    }

    public static void MapLocalizedMvcAttributeRoutes(this RouteCollection routes, string urlPrefix, RouteValueDictionary constraints)
    {
        var routeCollectionRouteType = Type.GetType("System.Web.Mvc.Routing.RouteCollectionRoute, System.Web.Mvc");
        var subRouteCollectionType = Type.GetType("System.Web.Mvc.Routing.SubRouteCollection, System.Web.Mvc");
        FieldInfo subRoutesInfo = routeCollectionRouteType.GetField("_subRoutes", BindingFlags.NonPublic | BindingFlags.Instance);

        var subRoutes = Activator.CreateInstance(subRouteCollectionType);
        var routeEntries = Activator.CreateInstance(routeCollectionRouteType, subRoutes);

        // Add the route entries collection first to the route collection
        routes.Add((RouteBase)routeEntries);

        var localizedRouteTable = new RouteCollection();

        // Get a copy of the attribute routes
        localizedRouteTable.MapMvcAttributeRoutes();

        foreach (var routeBase in localizedRouteTable)
        {
            if (routeBase.GetType().Equals(routeCollectionRouteType))
            {
                // Get the value of the _subRoutes field
                var tempSubRoutes = subRoutesInfo.GetValue(routeBase);

                // Get the PropertyInfo for the Entries property
                PropertyInfo entriesInfo = subRouteCollectionType.GetProperty("Entries");

                if (entriesInfo.PropertyType.GetInterfaces().Contains(typeof(IEnumerable)))
                {
                    foreach (RouteEntry routeEntry in (IEnumerable)entriesInfo.GetValue(tempSubRoutes))
                    {
                        var route = routeEntry.Route;

                        // Create the localized route
                        var localizedRoute = CreateLocalizedRoute(route, urlPrefix, constraints);

                        // Add the localized route entry
                        var localizedRouteEntry = CreateLocalizedRouteEntry(routeEntry.Name, localizedRoute);
                        AddRouteEntry(subRouteCollectionType, subRoutes, localizedRouteEntry);

                        // Add the default route entry
                        AddRouteEntry(subRouteCollectionType, subRoutes, routeEntry);


                        // Add the localized link generation route
                        var localizedLinkGenerationRoute = CreateLinkGenerationRoute(localizedRoute);
                        routes.Add(localizedLinkGenerationRoute);

                        // Add the default link generation route
                        var linkGenerationRoute = CreateLinkGenerationRoute(route);
                        routes.Add(linkGenerationRoute);
                    }
                }
            }
        }
    }

    private static Route CreateLocalizedRoute(Route route, string urlPrefix, RouteValueDictionary constraints)
    {
        // Add the URL prefix
        var routeUrl = urlPrefix + route.Url;

        // Combine the constraints
        var routeConstraints = new RouteValueDictionary(constraints);
        foreach (var constraint in route.Constraints)
        {
            routeConstraints.Add(constraint.Key, constraint.Value);
        }

        return new Route(routeUrl, route.Defaults, routeConstraints, route.DataTokens, route.RouteHandler);
    }

    private static RouteEntry CreateLocalizedRouteEntry(string name, Route route)
    {
        var localizedRouteEntryName = string.IsNullOrEmpty(name) ? null : name + "_Localized";
        return new RouteEntry(localizedRouteEntryName, route);
    }

    private static void AddRouteEntry(Type subRouteCollectionType, object subRoutes, RouteEntry newEntry)
    {
        var addMethodInfo = subRouteCollectionType.GetMethod("Add");
        addMethodInfo.Invoke(subRoutes, new[] { newEntry });
    }

    private static RouteBase CreateLinkGenerationRoute(Route innerRoute)
    {
        var linkGenerationRouteType = Type.GetType("System.Web.Mvc.Routing.LinkGenerationRoute, System.Web.Mvc");
        return (RouteBase)Activator.CreateInstance(linkGenerationRouteType, innerRoute);
    }
}

Then it is just a matter of calling this method instead of MapMvcAttributeRoutes.

public class RouteConfig
{
    public static void RegisterRoutes(RouteCollection routes)
    {
        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

        // Call to register your localized and default attribute routes
        routes.MapLocalizedMvcAttributeRoutes(
            urlPrefix: "{culture}/", 
            constraints: new { culture = new CultureConstraint(defaultCulture: "nl", pattern: "[a-z]{2}") }
        );

        routes.MapRoute(
            name: "DefaultWithCulture",
            url: "{culture}/{controller}/{action}/{id}",
            defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional },
            constraints: new { culture = new CultureConstraint(defaultCulture: "nl", pattern: "[a-z]{2}") }
        );

        routes.MapRoute(
            name: "Default",
            url: "{controller}/{action}/{id}",
            defaults: new { culture = "nl", controller = "Home", action = "Index", id = UrlParameter.Optional }
        );
    }
}
Tuesday, June 1, 2021
 
Eugenie
answered 7 Months ago
89

The problem with your route is that by using :any you match, actually...ANY route, so you're pretty much stuck there. I think you might have two solutions:

1)You can selectively re-route all your sites controller individually, like:

$route['aboutus'] = "aboutus";
$route['where-we-are'] = "whereweare";
//And do this for all your site's controllers
//Finally:
$route['(:any)'] = "polica/ogled/$1";

All these routes must come BEFORE the ANY, since they are read in the order they are presented, and if you place the :any at the beginning it will happily skip all the rest.

EDIT after comment:

What I mean is, if you're going to match against ANY segment, this means that you cannot use any controller at all (which is, by default, the first URI segment), since the router will always re-route you using your defined law. In order to allow CI to execute other controllers (whatever they are, I just used some common web pages, but can be literally everything), you need to allow them by excluding them from the re-routing. And you can achieve this by placing them before your ANY rule, so that everytime CI passed through your routing rules it parses first the one you "escaped", and ONLY if they don't match anything it found on the URL, it passes on to the :ANY rule.

I know that this is a code verbosity nonetheless, but they'll surely be less than 6K as you said. Since I don't know the actual structure of your URLs and of your web application, it's the only solution that comes to my mind. If you provide further information, such as how are shaped the regular urls of your app, then I can update my answer

/end edit

This is not much a pratical solution, because it will require a lot of code, but if you want a design like that it's the only way that comes to my mind. Also, consider you can use regexes as the $route index, but I don't think it can work here, as your usernames are unlikely matchable in this fashion, but I just wanted to point out the possibility.

OR

2) You can change your design pattern slightly, and assign another route to usernames, something along the line of

$route['user/(:any)'] = "polica/ogled/$1";

This will generate quite pretty (and semantic) URLs nonetheless, and will avoid all the hassle of escaping the other routes.

Monday, July 5, 2021
 
cegfault
answered 5 Months ago
18

Try the following in your Global.asax:

public static void RegisterRoutes(RouteCollection routes)
{
    routes.IgnoreRoute("{resource}.axd/{*pathInfo}");

    // Web Forms default
    routes.MapPageRoute(
        "WebFormDefault",
        "",
        "~/default.aspx"
    );

    // MVC default
    routes.MapRoute(
        "Default",                          // Route name
        "{controller}/{action}/{id}",       // URL with parameters
        new { controller = "Home", action = "Index", id = UrlParameter.Optional }  // parameter default
    );
}

Also I don't think you'll need the .mvc portion of the Default route.

Thursday, October 14, 2021
 
knagode
answered 2 Months ago
94

All of your routes are basically the same. They are

{controller}/{action}/{param}/{param}

I say the same because the routing engine would not understand the difference between {controller}/{action}/{id} and {controller}/{action}/{title}

and the route engine would really just see

{controller}/{action}/{PageNumber}/{PostsPerPage}

and confuse all the routes with the first one.


Once the route engine sees your almost generic {optional}/{optional}/{optional}/{optional}/ route at the top, anything with 4 or fewer elements can fit it so it goes no further.


If, on the other hand, your routes had distinctive beginnings, rather than generic {Controller}:

routes.MapRoute("Blog", "Blog/{PageNumber}/{PostsPerPage}", new { controller = "blog", action = "list", PageNumber = UrlParameter.Optional, PostsPerPage = UrlParameter.Optional });
routes.MapRoute("Code", "Code/{title}", new { controller = "code", action = "list", title = UrlParameter.Optional });
routes.MapRoute("Default", "{controller}/{action}/{id}", new { controller = "home", action = "index", id = UrlParameter.Optional });

Now, every time the routing engine sees Blog/... it understands that only 1 route can match. With all others, it will move on looking for a match. Whenever it sees Code/..., again only 1 route is a match. And Default will handle anything like {Controller}/{Action}/{id} that doesn't have Blog or Code as a first route param.

Wednesday, November 24, 2021
 
Marc Gravell
answered 1 Week ago
Only authorized users can answer the question. Please sign in first, or register a free account.
Not the answer you're looking for? Browse other questions tagged :  
Share