Introduction
Localizing a general ASP.NET application is easy to implement. All we have to do is assign a specified culture info to Thread.CurrentThread.CurrentUICulture
and use resource to display the localized text. There are two common ways to do this task:
Manually change CurrentUICulture in Application.BeginRequest event
The implementation will look something like this:
protected void Application_BeginRequest(object sender, EventArgs e)
{
var cultureName = HttpContext.Current.Request.UserLanguages[0];
Thread.CurrentThread.CurrentUICulture = new CultureInfo(cultureName);
}
Let ASP.NET set the UI culture automatically based on the values that are sent by a browser
We get it by changing web.config. The configuration will look like this:
<system.web>
<globalization enableClientBasedCulture="true" uiCulture="auto:en"/>
</system.web>
The problem is that culture info is stored in UserLanguages
which is defined by browser. If user wants to change to another language, he must go to "Internet Options" (IE for example) to change the Language Preference. This technique also has a problem for search engines because a search engine cannot classify two different language versions. It causes our page rank to not be promoted. Generally, we solve this problem by using URL to define culture (we will call it as localized URL). So, a URL will be change from http://domain/action/ to http://domain/[culture-code]/action (for example: http://domain/vi-VN/action) Then, in Application.BeginRequest
event, we can change UICulture
like this:
protected void Application_BeginRequest(object sender, EventArgs e)
{
var cultureName =
HttpContext.Current.Request.RawUrl.SubString(1, 5);
Thread.CurrentThread.CurrentUICulture = new CultureInfo(cultureName);
}
Everything seems to be OK until the birth of ASP.NET MVC. Routing technique which is used by ASP.NET MVC framework causes problem for this localization technique because routing engine needs to parse a URL to create the right controller. Appending a prefix before the real URL will make a wrong result for the routing engine. So, we need a way to combine both of ASP.NET MVC and localized URL.
Background
In order to make ASP.NET MVC work, we have to map one (or more) routes into Routes collection of RouteTable
. When you create an ASP.NET MVC application, VS.NET generates some code in global.asax.cs (.vb or something else depending on your language). The code will look like:
public class MvcApplication : System.Web.HttpApplication
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(
"Default",
"{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = "" }
);
}
protected void Application_Start()
{
AreaRegistration.RegisterAllAreas();
RegisterRoutes(RouteTable.Routes);
}
}
MapRoute
function will create a System.Web.Routing.Route
instance and add it to routes collection. A route will determine whether a URL is matched with its definition and which controller will process the URL. So, we can add our code to solve localized URL's problem.
Using the Code
Next, we need to create a new route which can handle localized URL. The new class will parse URL to generate RouteData
(which contains controller, action and other parameters). Here is the point. After extracting the culture code to set UICulture
, we'll simple rewrite the path. So, our request will be performed as normal.
public override RouteData GetRouteData(HttpContextBase httpContext)
{
string virtualPath = httpContext.Request.AppRelativeCurrentExecutionFilePath;
if (virtualPath.IsLocalizedUrl())
{
string cultureCode = virtualPath.Substring(2, 5);
CultureInfo culture = null;
try
{
culture = CultureInfo.GetCultureInfo(cultureCode);
}
catch { }
if (culture != null)
{
System.Threading.Thread.CurrentThread.CurrentUICulture = culture;
}
virtualPath = "~" + httpContext.Request.RawUrl;
virtualPath = string.Concat("~/",
(virtualPath.Length <= 8 ? string.Empty : virtualPath.Substring(8)));
httpContext.RewritePath(virtualPath, true);
}
RouteData data = base.GetRouteData(httpContext);
return data;
}
IsLocalizedUrl
is an extension which determines whether a URL is localized URL. The code is here:
public static bool IsLocalizedUrl(this string url)
{
int length = url.Length;
if (length < 7)
return false;
else if (length == 7)
return url[4] == '-';
else
return url.Length > 7 && url[4] == '-' && url[7] == '/';
}
The localized URL works well now. However, there is another problem with this implementation. When user uses Html.ActionLink
(or some other similar functions), the generated URL won't contains culture code. The reason is that VirtualPath
of processed URL doesn't contains culture code (which is removed for localization purpose). So, we need to override GetVirtualPath
function to make our expected virtual path.
public override VirtualPathData GetVirtualPath
(RequestContext requestContext, RouteValueDictionary values)
{
VirtualPathData data = base.GetVirtualPath(requestContext, values);
if (data != null)
{
string rawUrl = requestContext.HttpContext.Request.RawUrl;
if (rawUrl.IsLocalizedUrl(true))
{
data.VirtualPath = string.Concat(rawUrl.GetLocalizedCultureCode(),
"/", data.VirtualPath);
}
}
return data;
}
Everything is almost done here. There is only one small problem left: How to use register an LocalizedRoute
into RouteTable
. Of course, we can create a new instance of LocalizedRoute
and add it to RouteTable.Routes
but that code will look ugly. Fortunately, we have a better way: function extensions, which is also used by standard Route.
public static class RegexRouteCollectionExtensions
{
public static Route MapRoute(this RouteCollection routes, string name,
string url, object defaults)
{
return routes.MapRoute(name, url, defaults, null, null);
}
public static Route MapRoute(this RouteCollection routes, string name,
string url, object defaults, object constraints)
{
return routes.MapRoute(name, url, defaults, constraints, null);
}
public static Route MapRoute(this RouteCollection routes, string name,
string url, object defaults, object constraints, string[]namespaces)
{
if (routes == null)
{
throw new ArgumentNullException("routes");
}
if (url == null)
{
throw new ArgumentNullException("url");
}
LocalizedRoute item = new LocalizedRoute(url, new MvcRouteHandler())
{
Defaults = new RouteValueDictionary(defaults),
Constraints = new RouteValueDictionary(constraints),
DataTokens = new RouteValueDictionary()
};
if ((namespaces != null) && (namespaces.Length > 0))
{
item.DataTokens["Namespaces"] = namespaces;
}
routes.Add(name, item);
return item;
}
}
Working with Area
It's very easy to make it works with MVC's Area. All you need is replace AreaRegistrationContext.MapRoute()
function with by a similar version of RegexRouteCollectionExtensions.MapRoute()
function. Because AreaRegistrationContext.MapRoute()
function is not an extension method, we have to use another name for this task. So, I choose AreaRegistrationContext.MapLocalizedRoute
. The code will look like:
public static Route MapLocalizedRoute(this AreaRegistrationContext context,
string name, string url, object defaults, object constraints, string[] namespaces)
{
if ((namespaces == null) && (context.Namespaces != null))
{
namespaces = context.Namespaces.ToArray();
}
Route route = context.Routes.MapRoute(name, url, defaults, constraints, namespaces);
route.DataTokens["area"] = context.AreaName;
bool flag = (namespaces == null) || (namespaces.Length == 0);
route.DataTokens["UseNamespaceFallback"] = flag;
return route;
}
The above code is exactly the implementation of AreaRegistrationContext.MapRoute
. However, context.Routes.MapRoute
won't use System.Web.Mvc.RouteCollectionExtensions.MapRoute()
function, it uses our RegexRouteCollectionExtensions.MapRoute()
function. We also have to replace all overloads of AreaRegistrationContext.MapRoute
.
Next, we just go to our AreaRegistration
and replace MapRoute()
function by our MapLocalizedRoute()
function.
Before
public override void RegisterArea(AreaRegistrationContext context)
{
context.MapRoute(
"RtfRenderer_default",
"RtfRenderer/{controller}/{action}/{id}",
new { action = "Index", id = UrlParameter.Optional }
);
}
After
public override void RegisterArea(AreaRegistrationContext context)
{
context.MapLocalizedRoute(
"RtfRenderer_default",
"RtfRenderer/{controller}/{action}/{id}",
new { action = "Index", id = UrlParameter.Optional }
);
}
Points of Interest
Actually, in my real application, I use the following code to map my route:
routes.MapRoute(
"Default",
"{controller}" + Config.RoutingExtension + "/{action}/{id}",
new { controller = "Home", action = "Index", id = "" }
);
routes.MapRoute(
"Default2",
"{controller}/{action}/{id}",
new { controller = "Home", action = "Index", id = "" }
);
Config.RoutingExtension
is a string
constant (I set it as ".aspx"). The reason is that in IIS6, a URL like http://domain/action/id won't be performed by ASP.NET. By default, ASP.NET just handles some extension like .aspx, .ashx, .axd and .mvc (in case we install MVC framework in server). So, our request will be ignored by ASP.NET engine. There is a work-around solution for this problem. We can make our URL into http://domain/action.aspx/id. So, it'll be recognized by the ASP.NET engine.
Note
The attached project is in VS.NET 2010 RC. However, the code should work with VS.NET 2008. All code is in ASP.NET MVC 2.0. I have not checked it yet, but I think it can't work well with ASP.NET MVC 1.0. Tell me the result if you try this with MVC 1.0. :-)
History
- April 13, 2010: First release
- August 26, 2010: Make it work with MVC's Areas