Asp.Net MVC 5 Culture in Route and Url

ASP.NET MVC 5 culture in route and url

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 }
);
}
}

ASP.Net MVC 5 routing with culture url

That is because the first and second route both match. The optional parameters cause the application to choose the first one that matches: so the first one always gets picked.

Try this:

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

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

It works over here for (retulting culture behind them):

http://host/ (en-us)
http://host/Home/Index (xx-xx)
http://host/zh-cn/Home/Index (zh-cn)

MVC 5 content globalization

  • For anonymous users = for static pages the localization is determined by the {culture} route parameter in the URL, but for dynamic pages the {culture} parameter is eliminated, the site interface is determined from a cookie and the {content} parameter in the end of the URL stands for the content localization.

Since you are concerned with SEO, cookies are off-limits on anonymous pages. All content on these pages should be controlled via URL.

For this scenario, I think it would be most intuitive to put both the {culture} and {content} parameter into the URL, but at opposite ends.

{culture}/{controller}/{action}/{content}

You could potentially make both of these parameters optional with a logical default with a carefully crafted routing configuration. For readability, you might consider adding a static /content/ segment before the last parameter, which will require an additional route to make both segments optional.

the site interface localization will be determined from the cookie or browser (user-languages) when there's no cookie.

This is fine (assuming you use the URL not a cookie for the optional value as I mentioned above). This provides an easy way user to override the browser (user-languages) via URL. Since (user-languages) is a header that can be altered by firewalls, this may not be the user's true preference and if you don't provide a way to override it is a horrible UX. I also suggest making the way to override user-languages visible on the UI (via a link to the URL with the culture in it), not just available via URL.

Keep in mind search engines may not provide this header, so you should also have a fallback plan (default culture) when it is not available. Always consider the URL to be the first choice for culture when provided and ignore the (user-languages) header.

  • For authenticated users = for all pages (static & dynamic) the localization is determined only by the cookie, this makes a short & friendly URL and SEO is not affected.

You have more flexibility for URLs that can only reached by authenticated users, so using a cookie here is fine. Although, if the rest of the site puts the culture in the URL that might feel a bit odd to power users who use the URL to navigate.

Personally, I would aim for consistency with the rest of the site rather than "nice short URLs" for authenticated pages, since it is usually only anonymous pages where "nice short URLs" count for both SEO and sharing purposes. But, if you are expecting to have a lot of power users that edit the URL in the browser it may be worth considering.

AspNetMvc appends url when navigating between two different routes

Based on @NightOwl888 answer and difference between RouteLink and ActionLink, I found the solution for both hard-coded approach and T4MVC approach.

1) Hard-Coded approach: route name must be specified:

//signiture: RouteLink(this HtmlHelper htmlHelper, string linkText, string routeName, object routeValues);
@Html.RouteLink("Back to Doctors","adpan_default", new { controller = "Doctors", action = "Index"})

resulting url: http://localhost/adpan/Doctors

2) T4MVC approach: route name must be specified:

//signiture: RouteLink(this HtmlHelper htmlHelper, string linkText, string routeName, ActionResult result, IDictionary<string, object> htmlAttributes)
@Html.RouteLink("Back to Doctors","adpan_default", MVC.adpan.Doctors.Index().AddRouteValues(new {Area=""}), null)

resulting url: http://localhost/adpan/Doctors

Why AddRouteValues(new {Area=""}) ?

Due to the discussion here, it seems like a bug in T4MVC. Below route link adds ?Area=adpan as a futile parameter to the url:

@Html.RouteLink("Back to Doctors", "adpan_default", MVC.adpan.Doctors.Index(), null)

resulting url: http://localhost/adpan/Doctors?Area=adpan

However, this can be a trick to cheat over unwanted url parameters in T4MVC.

Define route for specific URL

The solution is to define two routing, with and without culture

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

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


Related Topics



Leave a reply



Submit